pax_global_header00006660000000000000000000000064152645253130014520gustar00rootroot0000000000000052 comment=08f0104dacaf205a85692ec64367ff4b861e9981 astropy-photutils-24be529/000077500000000000000000000000001526452531300156105ustar00rootroot00000000000000astropy-photutils-24be529/.flake8000066400000000000000000000000571526452531300167650ustar00rootroot00000000000000[flake8] max-line-length = 79 exclude = extern astropy-photutils-24be529/.github/000077500000000000000000000000001526452531300171505ustar00rootroot00000000000000astropy-photutils-24be529/.github/dependabot.yml000066400000000000000000000010461526452531300220010ustar00rootroot00000000000000# 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-24be529/.github/workflows/000077500000000000000000000000001526452531300212055ustar00rootroot00000000000000astropy-photutils-24be529/.github/workflows/check_changelog.yml000066400000000000000000000011321526452531300250110ustar00rootroot00000000000000name: 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@92735146b118b1dd2951cb4a45d60c2d8b47a153 # 0.5 env: CHANGELOG_FILENAME: CHANGES.rst GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} astropy-photutils-24be529/.github/workflows/check_milestone.yml000066400000000000000000000022321526452531300250630ustar00rootroot00000000000000name: 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.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-24be529/.github/workflows/ci_cron_daily.yml000066400000000000000000000052061526452531300245310ustar00rootroot00000000000000name: 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 permissions: contents: read env: TOXARGS: '-v' jobs: tests: 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, '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.15' tox_env: 'py315-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] files: ./coverage.xml verbose: true astropy-photutils-24be529/.github/workflows/ci_cron_weekly.yml000066400000000000000000000121161526452531300247250ustar00rootroot00000000000000name: 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 permissions: contents: read env: TOXARGS: '-v' IS_CRON: 'true' jobs: tests: 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, '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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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. The i386 image # runs natively on the x86_64 runner. Debian's i386 compiler uses the # x87 FPU, whose extended-precision intermediates the other # architectures do not exercise. runs-on: ubuntu-latest name: More architectures 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, 'Arch CI') ) env: ARCH_ON_CI: ${{ matrix.arch }} strategy: fail-fast: false matrix: include: - arch: s390x - arch: ppc64le - arch: armv7 - arch: none distro: none base_image: --platform=linux/386 i386/debian:sid steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - uses: uraimo/run-on-arch-action@460cb8e6d9f726a588fc9b5e681c8a6cab09ae41 # v3.2.0 name: Run tests id: build with: arch: ${{ matrix.arch }} distro: ${{ matrix.distro || 'ubuntu_rolling' }} base_image: ${{ matrix.base_image }} 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 \ python3-pytest-astropy-header \ python3-pytest-doctestplus \ python3-pytest-remotedata \ python3-setuptools-scm \ python3-scipy \ 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 # Update older versions 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-24be529/.github/workflows/ci_test_predeps.yml000066400000000000000000000032641526452531300251110ustar00rootroot00000000000000name: Test Pre-release Dependencies on: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: tests: name: ${{ matrix.os }}, ${{ matrix.tox_env }} runs-on: ${{ matrix.os }} strategy: fail-fast: true matrix: include: - os: ubuntu-latest python: '3.14' tox_env: 'py314-test-alldeps-predeps' toxposargs: --remote-data - os: macos-latest python: '3.14' tox_env: 'py314-test-alldeps-predeps' - os: windows-latest python: '3.14' tox_env: 'py314-test-alldeps-predeps' steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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 }} astropy-photutils-24be529/.github/workflows/ci_tests.yml000066400000000000000000000077151526452531300235570ustar00rootroot00000000000000name: 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 permissions: contents: read env: TOXARGS: '-v' 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.12' tox_env: 'py312-test-oldestdeps' 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: 'asdf-schemas' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.15' tox_env: 'py315-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] files: ./coverage.xml verbose: true astropy-photutils-24be529/.github/workflows/codeql.yml000066400000000000000000000063171526452531300232060ustar00rootroot00000000000000# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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-24be529/.github/workflows/publish.yml000066400000000000000000000055241526452531300234040ustar00rootroot00000000000000name: 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: - 'main' tags: - '*' - '!*dev*' - '!*pre*' - '!*post*' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: build_and_publish: permissions: contents: none uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@22ddf09dfabb443adddfb9f861b7d41a787d6b1a # v3.0.3 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: upload_to_pypi: false save_artifacts: true test_extras: test test_command: pytest -p no:warnings --pyargs photutils targets: | # Linux wheels - cp3*-manylinux_x86_64 - target: cp3*-manylinux_aarch64 runs-on: ubuntu-24.04-arm # MacOS X wheels - cp3*-macosx_x86_64 - cp3*-macosx_arm64 # Windows wheels - cp3*-win_amd64 - cp3*-win_arm64 # 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: anaconda_token: ${{ secrets.anaconda_token }} upload: # This job publishes the built wheels 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: id-token: write environment: name: pypi url: https://pypi.org/project/photutils/ # We upload to PyPI for all tag pushes, except tags ending in .dev if: | needs.build_and_publish.result == 'success' && startsWith(github.ref, 'refs/tags/') && !endsWith(github.ref, '.dev') && ( github.event_name == 'push' || github.event_name == 'workflow_dispatch' ) name: Upload release to PyPI runs-on: ubuntu-latest needs: [build_and_publish] steps: - name: Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: merge-multiple: true pattern: dist-* path: dist - name: Upload to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 astropy-photutils-24be529/.github/workflows/zizmor.yml000066400000000000000000000013661526452531300232700ustar00rootroot00000000000000name: zizmor GHA Security Analysis on: pull_request: branches: ['main'] paths: - '.github/workflows/**' push: branches: ['main'] paths: - '.github/workflows/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: {} jobs: zizmor: runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Run zizmor 🌈 uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 with: advanced-security: false annotations: true astropy-photutils-24be529/.gitignore000066400000000000000000000015621526452531300176040ustar00rootroot00000000000000# Compiled files *.py[cod] *.a *.o *.so *.whl __pycache__ # Ignore .c files by default to avoid including generated code. If you want to # add a nongenerated .c extension, use `git add -f filename.c`. *.c # Other generated files */version.py */cython_version.py MANIFEST *oldest_deps*.txt requirements-min.txt 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] *~ dev/ # Eclipse editor project files .project .pydevproject .settings # PyCharm editor project files .idea # Visual Studio Code project files .vscode # Mac OSX .DS_Store astropy-photutils-24be529/.pre-commit-config.yaml000066400000000000000000000114151526452531300220730ustar00rootroot00000000000000ci: 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.16.10' hooks: - id: ruff-check args: ['--fix', '--show-fixes'] - repo: https://github.com/scientific-python/cookie rev: 2026.08.14 hooks: - id: sp-repo-review - repo: https://github.com/pycqa/isort rev: 9.0.2 hooks: - id: isort name: isort (python) additional_dependencies: [toml] - id: isort name: isort (cython) types: [cython] additional_dependencies: [toml] - repo: https://github.com/PyCQA/flake8 rev: 7.4.1 hooks: - id: flake8 args: ['--ignore', 'E501,W503'] - repo: https://github.com/MarcoGorelli/cython-lint rev: v0.21.1 hooks: - id: cython-lint - repo: https://github.com/zizmorcore/zizmor-pre-commit rev: v1.30.1 hooks: - id: zizmor - repo: https://github.com/rbubley/mirrors-prettier rev: v3.9.9 hooks: - id: prettier types_or: [css, rst, json, yaml, toml, markdown] args: ['--single-quote'] # Exclude codeql.yml and dependabot.yml exclude: | (?x)^( (.*\/)?(codeql\.yml|dependabot\.yml)$ ) - repo: https://github.com/adrienverge/yamllint rev: v1.38.0 hooks: - id: yamllint name: yamllint (ASDF resources) # Prettier does not reach inside the block scalars that hold the # schema examples, so the line length is enforced here. The tag # and schema URIs cannot be wrapped, so a value that is a single # long word is exempt. files: ^photutils/resources/.*\.yaml$ args: - '-d' - '{rules: {line-length: {max: 79, allow-non-breakable-inline-mappings: true}}}' - repo: https://github.com/codespell-project/codespell rev: v2.4.3 hooks: - id: codespell args: ['--write-changes'] additional_dependencies: - tomli - repo: https://github.com/numpy/numpydoc rev: v1.11.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/sphinx-contrib/sphinx-lint rev: v1.0.2 hooks: - id: sphinx-lint exclude: '(docs/_build/|photutils/extern/)' astropy-photutils-24be529/.pycodestyle000066400000000000000000000000641526452531300201550ustar00rootroot00000000000000[pycodestyle] max-line-length = 79 exclude = extern astropy-photutils-24be529/.readthedocs.yaml000066400000000000000000000007021526452531300210360ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 apt_packages: - graphviz tools: python: '3.14' 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-24be529/CHANGES.rst000066400000000000000000006775151526452531300174370ustar00rootroot000000000000003.1.1 (2026-10-16) ------------------ General ^^^^^^^ - Importing the top-level ``photutils`` package and subpackages is now faster. [#2512] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed the ``to_pixel`` methods of ``SkyEllipticalAperture``, ``SkyEllipticalAnnulus``, ``SkyRectangularAperture``, and ``SkyRectangularAnnulus`` for an aperture whose position frame differs from the WCS celestial frame (e.g., ICRS positions with a Galactic WCS). The rotation angle ``theta`` is again measured in the frame of the aperture position. [#2514] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Restored the ``photutils.aperture.stats.DEFAULT_COLUMNS`` constant, which was removed in 3.1.0 without a deprecation period. It contains the 3.0 default columns, which do not include the ``flags`` column. It is now deprecated and will be removed in version 4.0. Use the ``ApertureStats.default_columns`` attribute instead. [#2517] - ``photutils.segmentation`` - Restored the ``photutils.segmentation.catalog.DEFAULT_COLUMNS`` constant, which was removed in 3.1.0 without a deprecation period. It contains the 3.0 default columns, which do not include the ``flags`` column. It is now deprecated and will be removed in version 4.0. Use the ``SourceCatalog.default_columns`` attribute instead. [#2517] 3.1.0 (2026-10-13) ------------------ General ^^^^^^^ - The minimum required Python is now 3.12. [#2366] - The minimum required astropy is now 7.0. [#2401] - The minimum required SciPy is now 1.15. [#2365] - The minimum required regions is now 0.10. [#2266] - The minimum required Matplotlib is now 3.10. [#2401] - The minimum required gwcs is now 0.22. [#2266] - The minimum required Shapely is now 2.1. [#2266] - The minimum required tqdm is now 4.67. [#2266] - The ``asdf`` and ``asdf-astropy`` packages are now optional dependencies. [#2211] - The ``pytest-astropy`` package is no longer required to run the test suite. The ``test`` extra now lists the individual pytest plugins. [#2486] - scikit-image is no longer an optional dependency. The compiled watershed kernel replaced its only runtime use. [#2439] - SciPy is now also a build-time dependency. Its Cython interface to the Brent root finder is used by the compiled code that computes the ``SourceCatalog`` flux radii. [#2406] - All lazily computed class attributes now use ``functools.cached_property`` instead of the astropy ``lazyproperty`` decorator. [#2366] New Features ^^^^^^^^^^^^ - Added an ASDF extension with converters to serialize all photutils apertures and PSF models. [#2211, #2335, #2341, #2498] - ``photutils.aperture`` - Added a new ``AperturePhotometry`` class, which is now the recommended tool for aperture photometry. It uses a results-object pattern, similar to ``ApertureStats``. The class is immutable after construction, so instances are thread-safe and independent frames can be processed concurrently. [#2328, #2331] - Added an ``n_threads`` keyword to ``AperturePhotometry`` and ``ApertureStats`` to compute the aperture sums and statistics using multiple threads. The aperture positions are divided into chunks and processed concurrently, producing results identical to the single-threaded computation. [#2367] - Rectangular apertures now support the ``method='exact'`` mask mode. Previously this fell back to a 32x subpixel approximation. [#2291] - Significantly improved the performance of aperture photometry, typically by factors of ~2-25 depending on the aperture shape and overlap method, by computing all sources in a single call into compiled code. The computation also releases the GIL and uses no global state, so aperture photometry can now be parallelized across threads, including on free-threaded Python builds. [#2292, #2441, #2451] - Added new ``PolygonAperture`` and ``SkyPolygonAperture`` classes for polygon apertures defined by a fixed shape applied at one or more positions. [#2297] - Added ``to_polygon`` methods to the circular, elliptical, and rectangular apertures (both pixel and sky variants) to convert them to ``PolygonAperture`` or ``SkyPolygonAperture`` objects. [#2298] - Added ``segmentation_image``, ``labels``, and ``mask_method`` keywords to ``AperturePhotometry`` and ``ApertureStats`` to mask or correct the flux from neighboring sources within an aperture using a ``SegmentationImage``. [#2309, #2321, #2328, #2440] - Significantly improved the performance of elliptical, rectangular, and convex polygon aperture photometry, typically by a factor of ~2. [#2310] - Significantly improved the performance of ``ApertureStats``, typically by factors of ~5-15 depending on the requested properties, by computing all sources in a single call into compiled code. Sigma-clipped statistics are also computed in compiled code for the common ``SigmaClip`` configurations. [#2314, #2446] - Added ``mean_err`` and ``median_err`` properties to ``ApertureStats`` providing the standard error of the mean and the standard error of the median, respectively, for each aperture. [#2314] - Added a ``ddof`` keyword to ``ApertureStats`` to set the delta degrees of freedom used when computing the ``var`` and ``std`` properties. The default is ``ddof=0`` (population variance), and ``ddof=1`` gives the sample (unbiased) variance. [#2314] - Added per-source bitwise quality flags to aperture photometry and statistics. The ``AperturePhotometry`` and ``ApertureStats`` classes provide a ``flags`` attribute (and a ``'flags'`` column, or ``'flags_'`` columns for multiple apertures, in their default ``to_table()`` output). The ``ApertureStats.flags`` property reports conditions from both the "center"-method footprint used by the value statistics and the ``sum_method`` footprint used by the sum properties. A new ``decode_aperture_flags`` function decodes the flag values into human-readable names, and both classes have a ``decode_flags`` convenience method that returns a dictionary keyed by aperture position id. [#2327, #2328, #2362, #2379, #2400] - Added validation of the ``ApertureStats.to_table()`` ``columns`` keyword. [#2329] - Improved the performance of the sigma-clipped ``ApertureStats`` order statistics (``min``, ``max``, ``median``), ``mad_std``, and biweight properties. [#2364] - Added an ``as_ellipse`` keyword to the ``to_pixel`` and ``to_sky`` methods of ``CircularAperture``, ``CircularAnnulus``, ``SkyCircularAperture``, and ``SkyCircularAnnulus``. If `True`, the image of the circle under the local linear WCS is returned as an elliptical aperture, which differs from a circle when the pixels are not square or the WCS is sheared. [#2428] - The ``to_pixel`` methods of the sky apertures now invert the WCS once, for the aperture positions, and reuse that pixel position for the shape conversion. The elliptical and rectangular annuli convert both of their shapes with one evaluation of the local WCS Jacobian in each direction. Together with the faster WCS helpers, the ``to_sky`` and ``to_pixel`` methods of all apertures are two to five times faster. [#2425] - Added an ``ApertureStats`` ``sky_orientation`` property providing the position angle on the sky of the source major axis, measured from North toward East, computed by transporting the pixel covariance matrix through the local WCS Jacobian. [#2433] - ``photutils.background`` - Updated ``LocalBackground`` so that ``Quantity`` input data is supported, consistent with the background estimator classes and ``Background2D``. The result is a ``Quantity`` with the same unit as the input data. [#2363] - Added an ``n_threads`` keyword to ``Background2D`` to compute the box statistics using multiple threads. The results are identical to the single-threaded computation. The underlying sigma-clipping and statistics kernels release the GIL, so multithreading can significantly speed up the background estimation for large images. [#2363] - The ``Background2D`` ``n_threads`` keyword also multithreads the interpolation of the low-resolution meshes to the full-size maps returned by the ``background`` and ``background_rms`` properties (for the 'reflect' and 'mirror' interpolation boundary modes, with SciPy 1.16 or later). The multithreaded maps are identical to the single-threaded maps up to floating-point rounding. [#2365] - Significantly improved the performance of ``Background2D`` by computing the sigma clipping and the box statistics in a single combined pass in compiled code when the ``sigma_clip``, ``bkg_estimator``, and ``bkg_rms_estimator`` inputs are among the standard supported options. This includes all of the standard photutils background and background RMS estimator classes. The Cython code releases the GIL and composes with the ``n_threads`` keyword. Unsupported inputs (e.g., custom estimator callables) fall back to the previous implementation and produce the same results as before. [#2364] - Improved the performance of the background and background RMS estimator classes when called with ``axis=None`` (the default) and a supported ``sigma_clip`` by routing the sigma clipping through astropy's fast C implementation. [#2364] - Significantly improved the performance of ``LocalBackground`` when the ``bkg_estimator`` is a standard photutils estimator class, by computing the local backgrounds for all positions with the batched ``ApertureStats`` kernels instead of a Python loop over the apertures. [#2364] - Added an ``n_threads`` keyword to ``LocalBackground``, passed through to its internal ``ApertureStats`` computation to measure the local backgrounds using multiple threads. The results are identical to the single-threaded computation. [#2367] - ``photutils.centroids`` - Added a ``centroid_symmetry`` function that calculates the center of a source as its point of maximal point symmetry. It is the default ``recentering_func`` of ``EPSFBuilder``, which centers an ePSF on its core instead of its center of mass. [#2475, #2487, #2488] - ``photutils.detection`` - Added validation of the ``StarFinderCatalogBase.to_table()`` ``columns`` keyword. This is the base class used by the ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` catalogs. The ``columns`` keyword now accepts a single string in addition to a list of column names. [#2329] - Added a ``min_n_pixels`` keyword to ``IRAFStarFinder`` to set the minimum number of positive sky-subtracted pixels within the kernel footprint that a source must have. It is equivalent to the ``npixmin`` parameter in IRAF's starfind. [#2510] - ``photutils.isophote`` - Optimized the ``build_ellipse_model_c`` Cython extension by adding module-level compiler directives (``boundscheck=False``, ``wraparound=False``, ``cdivision=True``), using C-contiguous memory views, declaring ``math.h`` functions as ``nogil``, and replacing Python function-pointer dispatch with a direct ``if``/``else`` branch for harmonic corrections. The module is now also marked as ``freethreading_compatible=True``. [#2293] - Added validation of the ``IsophoteList.to_table()`` ``columns`` keyword. Also restored the ``npix_e`` and ``npix_c`` properties to the ``columns='all'`` output. [#2329] - ``photutils.profiles`` - Improved the performance of the profile classes by computing the aperture photometry and unmasked areas for all radii in a single batched ``AperturePhotometry`` call instead of one photometry and one area calculation per radius. Profile construction is now ~3x faster. [#2371] - The profile classes are now thread-safe. All lazily computed attributes cache raw (unnormalized) values that are immutable once computed. [#2371] - ``photutils.psf`` - Improved ``GriddedPSFModel`` evaluation performance by ~20-25% through optimizations that reduce per-evaluation overhead and streamline interpolation calculations. [#2289, #2307] - ``GriddedPSFModel`` and ``ImagePSF`` now evaluate their interpolating splines, and the spline derivatives used by ``fit_deriv``, with a compiled kernel instead of separate ``scipy`` spline calls. For fit regions of 5 x 5 to 25 x 25 pixels, ``GriddedPSFModel`` evaluation is ~3-8x faster and its ``fit_deriv`` is ~6-14x faster, and ``ImagePSF`` evaluation is ~1.2-3x faster and its ``fit_deriv`` is ~2-5x faster. The models also cache only one set of spline coefficients, which is about a third of the memory that the cached splines used when ``fit_deriv`` is evaluated. The ``GriddedPSFModel`` cache is no longer included when the model is pickled. [#2455, #2456] - The ``ImagePSF.copy`` method now builds the cached interpolating spline on the model before copying it, so that the copies share the spline instead of each building their own. ``PSFPhotometry`` and ``IterativePSFPhotometry`` copy the model for every source and previously rebuilt the spline each time. PSF photometry with a 361 x 361 pixel PSF image is ~6x faster. [#2456] - Added a ``STDPSFGrid.grid_shape`` property returning the ``(ny, nx)`` shape of the ePSF grid. [#2347] - Added an ``n_threads`` keyword to ``PSFPhotometry`` and ``IterativePSFPhotometry`` to fit the source groups using multiple threads. The results are identical to the single-threaded computation. The fitting holds the Python global interpreter lock (GIL), so multithreading speeds up the fitting only on a free-threaded Python build. When the GIL is enabled, a warning is issued and the sources are fitted in a single thread. [#2457] - Added a ``GriddedPSFModel.grid_shape`` property returning the ``(ny, nx)`` shape of the ePSF grid, matching the ``STDPSFGrid`` property of the same name. It replaces the ``grid_shape`` metadata key, which has been removed. [#2360] - ``STDPSFGrid`` and the ``GriddedPSFModel`` ``read`` method now accept path-like inputs (e.g., ``pathlib.Path``) in addition to strings. [#2355] - ``SourceGrouper`` now scales to large source lists by computing the connected components of the minimum-separation graph with a KD-tree instead of building a dense pairwise distance matrix. [#2389] - Added an analytic Jacobian (``fit_deriv``) to ``ImagePSF`` and ``GriddedPSFModel``, which allows the fitter to skip the finite-difference Jacobian during PSF fitting. This removes roughly 60% of the model evaluations performed during fitting. With ``fill_value=None``, a point outside the PSF image takes the value at the nearest point on the image edge, so its position derivative along that axis is zero. [#2393, #2455, #2456] - Added ``MoffatPRF`` and ``AiryDiskPRF`` models, which are Moffat and Airy disk profiles integrated over the detector pixels. Unlike ``MoffatPSF`` and ``AiryDiskPSF``, they conserve flux for an undersampled PSF and give unbiased fluxes when fit to the pixel values of an image, with fittable shape parameters. [#2473] - Added an analytic Jacobian (``fit_deriv``) to ``GaussianPRF``, ``CircularGaussianPRF``, ``CircularGaussianSigmaPRF``, ``MoffatPSF``, and ``AiryDiskPSF``. Astropy fitters use it in place of finite-difference derivatives. [#2472] - The ``extract_stars`` function now accepts a plain 2D array, or a list of 2D arrays, as the input ``data``. An ``NDData`` object is needed only to input an uncertainty, a mask, or a WCS. [#2481] - Added a ``constrain_fluxes`` method to ``LinkedEPSFStar`` and a ``constrain_fluxes`` keyword to ``EPSFBuilder`` (default `True`) that constrains the fluxes of linked stars (the same star observed in multiple dithered images) to their mean value after each fitting iteration, in addition to their centers. This breaks the degeneracy between the flux of a star and its subpixel position caused by intra-pixel sensitivity variations, which would otherwise be absorbed into the ePSF. [#2420] - Added ``smoothing_kernel`` and ``fit_shape`` attributes to ``EPSFBuildResults`` that report the smoothing kernel and fitting box used in the final iteration. [#2421] - Added an ``alias_passband`` keyword to ``EPSFBuilder`` that controls the low-pass filter that is applied to an oversampled ePSF in each iteration. The filter removes structure that repeats with a period of one detector pixel or shorter. The keyword sets the highest spatial frequency that the filter leaves unchanged, in cycles per detector pixel, which corresponds to a period of ``1 / alias_passband`` detector pixels. The default of ``'auto'`` uses 0.8 (0.7 for an oversampling factor of 2). A value of 0.85 or 0.9 recovers the peak of the ePSF of a strongly undersampled detector, 0.7 rejects more noise for better-sampled data, and `None` turns the filter off. [#2465] - Added a ``refinement_iters`` keyword to ``EPSFBuilder`` (default 5). After the building iterations, the ePSF is refined for an oversampling factor of 4 or larger. Each refinement iteration updates the ePSF with the star centers and fluxes held fixed, using a weaker low-pass filter that keeps structure that repeats with a period of about one detector pixel, and then refits the stars. This restores the real signal of an undersampled ePSF that the alias low-pass filter of the building iterations removes, which otherwise shows as a ripple pattern along the row and the column through the center of the ePSF. The convergence attributes of ``EPSFBuildResults`` then describe the last refinement iteration. [#2465] - Added ``iteration_epsfs`` and ``iteration_info`` attributes to ``EPSFBuildResults`` that hold the ePSF image and the convergence statistics of each building and refinement iteration, an ``initial_epsf`` attribute that holds the image of the input ePSF (if any), and a ``plot_iterations`` method that plots the ePSF after each iteration and its change from the previous one. [#2466, #2477] - Added a ``center_asymmetry`` attribute to ``EPSFBuildResults`` that gives the offset of the center of mass of the built ePSF from its symmetry center, i.e., how much the measured star positions depend on the definition of the ePSF center. [#2488] - Added a ``wing_smoothing`` keyword to ``EPSFBuilder`` (default `True`). The wings of the final ePSF, beyond 3.5 FWHM from its center, are now smoothed with least-squares quadratic fits in boxes 1.25 to 1.75 FWHM wide, which lowers their noise. The core of the ePSF changes only by the renormalization of the smoothed ePSF (less than 0.03 percent in tests). The smoothing also removes real structure in the wings that is finer than about two FWHM, so set ``wing_smoothing=False`` for large star samples of high signal-to-noise. With an oversampling factor of 1 it can make the wings of a strongly undersampled ePSF less accurate. [#2467] - Added a ``converged_fraction`` keyword to ``EPSFBuilder`` (default 0.95) giving the fraction of the successfully fitted stars whose centers must change by less than ``center_accuracy`` between iterations for the build to converge, so that a few spurious or contaminated stars whose centers never settle do not prevent convergence. The fraction achieved in the final iteration is reported in the new ``final_converged_fraction`` attribute of ``EPSFBuildResults``. Set ``converged_fraction=1.0`` for the previous behavior. [#2422] - ``EPSFBuilder`` now builds the ePSF spline interpolators once per iteration instead of once per star and stacks the star residuals in a single vectorized operation instead of a loop over the stars, which makes each build iteration about 20% faster. [#2422] - Added a ``PSFPhotometry`` and ``IterativePSFPhotometry`` flag (bit 4096, ``position_outside_fit_region``) that is set when the fitted source position is outside the region of fitted data, which is the ``fit_shape`` box around the initial position, trimmed to the input image. [#2450] - Added a ``make_epsf_from_psf`` function that makes an ePSF image from an oversampled PSF image whose values are samples of the PSF and are not integrated over the detector pixels. Image PSF models made from such an image do not conserve flux for undersampled PSFs. The ``midpoints`` keyword gives an ePSF with the PSF center on its central grid point for a PSF that is centered on an even-sized image. [#2470] - ``photutils.segmentation`` - Added validation of the ``SourceCatalog.to_table()`` ``columns`` keyword. [#2329] - ``SegmentationImage`` objects now always have an ``info`` attribute, a dictionary containing auxiliary information. Segmentation images returned by ``deblend_sources`` store the input labels affected by deblending warnings under ``'non_positive_min_labels'`` and ``'too_many_markers_labels'`` keys. [#2378] - Added a ``get_label_mapping`` method to ``SegmentationImage`` to find the mapping of labels to another segmentation image defined on the same pixel grid, e.g., mapping the original (parent) labels to the deblended (child) labels between segmentation images made before and after deblending. [#2382] - Added ``SourceCatalog`` ``centroid_err``, ``x_centroid_err``, ``y_centroid_err``, ``centroid_win_err``, ``x_centroid_win_err``, ``y_centroid_win_err``, ``centroid_quad_err``, ``x_centroid_quad_err``, and ``y_centroid_quad_err`` properties providing the 1-sigma errors on the isophotal, windowed, and quadratic centroid positions, propagated from the input ``error`` array. [#2397] - Added ``SourceCatalog`` ``sky_centroid_err``, ``sky_centroid_ra_err``, ``sky_centroid_dec_err``, and the corresponding ``sky_centroid_win_*`` and ``sky_centroid_quad_*`` properties providing the 1-sigma sky position errors in arcsec, computed by transporting the pixel centroid error covariance through the local WCS Jacobian. [#2399] - Added a ``SourceCatalog`` ``sky_orientation`` property providing the position angle on the sky of the source major axis, measured from North toward East, computed by transporting the pixel covariance matrix through the local WCS Jacobian. [#2430] - Added a bitwise quality-flag system for segmentation. A new public ``SEGMENTATION_FLAGS`` registry and ``decode_segmentation_flags`` function were added. ``SegmentationImage`` objects now have a ``flags`` attribute with per-label flags, ``deblend_sources`` records per-source deblending provenance flags, and ``SourceCatalog`` has a new ``flags`` attribute and default table column combining provenance and measurement flags, along with a ``decode_flags`` convenience method that returns a dictionary keyed by source label. [#2402] - Significantly improved the performance of the ``SourceCatalog`` windowed centroids, quadratic centroids, Kron radii and photometry, ``flux_radius``, ``circular_photometry``, image moments, ``perimeter``, ``gini``, segment pixel statistics (e.g., ``segment_flux``, ``segment_area``, ``area``, and ``min_value``), and the positions of the minimum and maximum pixel values (e.g., ``min_value_index`` and ``max_value_xindex``), typically by factors of ~3-20 depending on the property, by computing all sources in a single call into compiled code. The ``flags`` attribute and the default ``to_table()`` output are about 5 times faster for large catalogs. No property displays a progress bar anymore (see the ``progress_bar`` deprecation under API Changes). [#2406, #2446, #2447] - Added an ``n_threads`` keyword to ``SourceCatalog`` to compute the compiled per-source measurements (the isophotal moments and their errors, the segment pixel statistics, the windowed and quadratic centroids, ``perimeter``, the Kron radii and photometry, ``circular_photometry``, and ``flux_radius``), and thus every property derived from them, using multiple threads. The sources are divided into chunks that are processed concurrently, producing results identical to the single-threaded computation. [#2407] - Significantly improved the performance of source deblending in ``deblend_sources`` and ``SourceFinder``, producing identical results. The multithreshold watershed markers are now built by compiled code. Deblending is typically ~7-30 times faster, both for fields of many small blended sources and for large segments with many markers. [#2408, #2413, #2463] - Added an ``n_threads`` keyword to ``deblend_sources`` and ``SourceFinder`` to deblend the sources using multiple threads. The sources are divided into chunks and processed concurrently, producing results identical to the single-threaded computation. Each chunk is deblended by a few compiled calls that release the GIL, so multithreading speeds up deblending for fields of many small sources as well as for large sources. [#2409, #2413] - Added a ``contrast_method`` keyword to ``deblend_sources`` and ``SourceFinder`` to select the flux used by the deblending contrast criterion. The default of `None` currently resolves to ``'basin'``, which preserves the existing behavior (the fraction is the total flux in the source's watershed basin, with iterative removal of below-contrast basins), and may change to ``'saddle'`` in version 4.0. The new ``'saddle'`` method instead measures the flux a source holds above the saddle level where it separates from its neighbors. [#2411] - Added a ``release_cache`` method to ``SourceCatalog`` to free the cached full-image working arrays used by the compiled code. Working copies are made when the input image arrays do not have a common ``float32`` or ``float64`` dtype. [#2446] - Added ``centroid_win_fallback`` and ``centroid_quad_fallback`` boolean properties to ``SourceCatalog`` that are `True` where the windowed or quadratic centroid was substituted by the isophotal centroid or peak pixel, or the value is NaN. [#2450] - Added a ``kron_radius_measured`` property to ``SourceCatalog`` that returns the measured unscaled Kron radius before the ``kron_params`` minimum values are applied. [#2459] - Significantly improved the performance of creating a ``SegmentationImage`` and of its ``labels``, ``areas``, and ``slices`` attributes, producing identical results. They are now measured by compiled code in a single pass over the segmentation array. Creating a ``SegmentationImage`` and getting its slices is typically ~3-15 times faster, depending on the number of labels and the fraction of labeled pixels. [#2464] - ``photutils.utils`` - Added a new ``DeblendWarning`` class, a subclass of astropy's ``AstropyUserWarning``, which is raised by ``deblend_sources`` when the deblending mode is changed to a fallback mode for one or more sources. [#2378] - Added a new ``PhotutilsDeprecationWarning`` class, a subclass of astropy's ``AstropyDeprecationWarning``, which is now used for all Photutils deprecation warnings. The class name identifies Photutils as the source of the warning. Existing warning filters for ``AstropyDeprecationWarning`` continue to match. [#2417] - Added ``compute_pixel_areas`` and ``compute_pixel_area_map`` functions to compute the on-sky area of image pixels from a WCS, either at an array of positions or for every pixel in an image. The map is interpolated from a coarse grid by default, or evaluated exactly at every pixel with ``interpolate=False``. [#2427, #2431] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed the local WCS Jacobian computation used by the aperture ``to_sky`` and ``to_pixel`` methods so that it is well-conditioned at the celestial poles and across the RA = 0/360 degree wraparound. [#2286] - Fixed ``RectangularAperture``, ``RectangularAnnulus``, and their sky counterparts to use the SVD-based ellipse ``to_sky`` and ``to_pixel`` conversions so that they round-trip correctly with a sheared WCS. [#2286] - Fixed the aperture ``to_sky`` and ``to_pixel`` conversions for elliptical and rectangular apertures (and their annulus variants) so that the orientation is correct for a WCS with a flipped parity (e.g., North down, East left). Previously, such apertures were mirrored about the x-axis. [#2288] - Fixed ``CircularAnnulus``, ``EllipticalAnnulus``, and ``RectangularAnnulus`` overlap masks so that pixel fractions are never negative. Previously, boundary pixels could contain tiny negative values (of order the floating-point epsilon) from subtracting the inner and outer overlaps. [#2314] - Fixed a corner case in ``ApertureStats`` where ``sum_aper_area`` was NaN while ``sum`` was finite. When all pixels selected by the "center" method are masked but unmasked boundary pixels still have nonzero ``sum_method`` overlap fractions, the fractional area of those pixels is now returned, consistent with ``sum``. [#2314] - Fixed the aperture photometry so that the returned flux errors always have the same length as the fluxes when ``error`` is not input. Previously, an all-NaN error array was returned only for sources with no overlap with the data. [#2316] - Fixed the elliptical and rectangular apertures (and their annulus and sky variants) so that reassigning ``theta`` after construction updates the aperture geometry. Previously, the rotation angle was computed only once, so the bounding box, aperture mask, and photometry silently continued to use the original angle. [#2348] - Fixed ``BoundingBox`` so that it can be used in a `set` or as a `dict` key. Defining ``__eq__`` without ``__hash__`` had left the class unhashable. [#2348] - Fixed ``PixelAperture.area_overlap`` so that it no longer modifies the data of the aperture mask it creates when a ``mask`` is input. [#2348] - Fixed the annulus apertures so that reassigning an inner or outer shape parameter after construction validates the inner < outer invariant. [#2362] - Fixed a thread-safety issue in ``ApertureStats`` by using a copy of the user's ``SigmaClip`` instance. [#2364] - Fixed the aperture photometry flux error propagation so that each pixel variance is weighted by the squared aperture overlap fraction. Previously, the linear overlap fraction was used, which overestimated the flux errors by a few percent for small apertures. This affects the flux errors from ``AperturePhotometry``, ``aperture_photometry``, and ``ApertureStats.sum_err``. The ``ApertureStats.error_sum_cutout`` values are now the pixel errors multiplied by the aperture mask weights, so their quadrature sum equals ``sum_err``. [#2398] - Fixed ``ApertureStats.error_sum_cutout`` being calculated in ``float32`` precision for a ``float32`` input ``error`` array. It is now calculated in ``float64``, as are all of the other statistics. [#2446] - Fixed the ``exact`` method of ``EllipticalAperture`` and ``EllipticalAnnulus`` (and their sky variants) returning incorrect overlap fractions, including values greater than 1, when a pixel edge is tangent to the ellipse or a pixel corner lies exactly on it. [#2497] - Fixed the ``ApertureStats`` ``centroid``, ``x_centroid``, ``y_centroid``, ``sky_centroid``, and ``sky_centroid_icrs`` properties for an aperture whose bounding box extends beyond the left or bottom edge of the data. Previously, the centroid was offset by the size of the part of the bounding box outside of the data. [#2505] - Fixed the ``to_sky`` and ``to_pixel`` methods of the elliptical and rectangular annulus apertures raising a ``ValueError`` for a sheared WCS when the inner and outer shapes have different aspect ratios. The inner shape is now the converted outer shape scaled by the input inner-to-outer size ratios, which is always a valid annulus and is recovered exactly by the inverse conversion. [#2505] - Fixed ``aperture_photometry`` and ``PixelAperture.do_photometry`` returning a sum of zero for an aperture whose bounding box overlaps the data but that has no weighted pixel inside the data. The sum is now NaN, the same as for an aperture whose bounding box does not overlap the data. [#2505] - Fixed ``ApertureStats`` raising an ``AttributeError`` for a ``regions.SkyRegion`` input without a ``wcs``. A ``ValueError`` is now raised, the same as for a ``SkyAperture``. [#2505] - Fixed ``BoundingBox.intersection`` returning a ``BoundingBox`` with zero width or height for two bounding boxes that share only an edge or a corner. `None` is now returned. [#2505] - ``photutils.background`` - Fixed the ``exclude_percentile`` box-exclusion criterion in ``Background2D`` so that a box is excluded only if more than ``exclude_percentile`` percent of its pixels are masked, as documented. Previously, a box with exactly that percentage of masked pixels was excluded, and ``exclude_percentile=0`` excluded all boxes, even fully unmasked ones. [#2363] - ``Background2D`` now raises a clear ``TypeError`` if the input ``mask`` or ``coverage_mask`` is not a boolean array. Previously, a nonboolean ``coverage_mask`` raised a cryptic ``IndexError`` (or could silently select the wrong pixels) when the background map was computed. [#2363] - Fixed ``BiweightLocationBackground`` and ``BiweightScaleBackgroundRMS`` raising an error for a scalar ``M`` input, either for a Python scalar with ``axis=None`` or for any scalar with a positive integer ``axis``. [#2492] - Fixed ``LocalBackground`` so that nonfinite data values in the annulus are always excluded. Previously, they were kept (giving a nonfinite or NaN result) for a custom ``bkg_estimator`` or one with ``sigma_clip=None``, depending on the estimator type. [#2492] - ``photutils.centroids`` - Fixed ``centroid_quadratic`` to fit the quadratic in coordinates centered on the peak pixel, avoiding ill-conditioning at large pixel coordinates that could cause failures and spurious NaN results. [#2368] - Fixed the centroid functions using the masked values of an input ``MaskedArray`` that has a hard mask. [#2491] - Fixed ``centroid_2dg`` to raise a ``ValueError`` if the unmasked data are constant. Previously, constant data with a masked or nonfinite pixel was fit and a meaningless position was returned. [#2491] - ``centroid_quadratic`` and ``CentroidQuadratic`` now emit a warning whenever ``fit_boxsize`` is larger than the data and is reduced. Previously, no warning was emitted if the data size along the axis was odd. [#2491] - ``photutils.datasets`` - Fixed ``make_wcs`` so that the WCS ``pixel_shape`` attribute is set in (nx, ny) order. The WCS transformation itself was unaffected. [#2374] - Fixed ``make_model_image`` raising a ``ValueError`` for a source whose model bounding box ends exactly at the lower edge of the image. Such sources do not overlap the image and are now skipped. [#2484] - ``photutils.detection`` - Fixed ``DAOStarFinder`` and ``IRAFStarFinder`` ``orientation`` output to return a value in the range (-90, 90] degrees instead of incorrectly wrapping it to [0, 360) degrees (which only ever produced values in the disjoint ranges [0, 90] and (270, 360)). This also makes it consistent with ``ApertureStats.orientation`` and ``SourceCatalog.orientation``. [#2317] - Fixed integer indexing of the star finder catalog classes when multidimensional per-source attributes (e.g., image moments or cutouts) had already been computed. Previously, the integer index dropped the leading source axis of those cached arrays, and accessing dependent properties on the indexed catalog raised an ``IndexError``. [#2377] - ``DAOStarFinder`` and ``IRAFStarFinder`` now raise a ``ValueError`` if any input ``xycoords`` position is outside the bounds of the input data. [#2377] - Fixed ``IRAFStarFinder`` returning no sources when the kernel footprint covers the entire kernel array (e.g., ``fwhm`` values between about 4.45 and 4.7 with the default ``sigma_radius``). There are then no pixels to estimate the local sky, which was NaN for every source. As in IRAF ``starfind``, the sky is now the central data value minus the central convolved data value in that case. [#2492] - Fixed the ``DAOStarFinder`` and ``IRAFStarFinder`` centroids being offset when the input ``xycoords`` are not integers. The input positions are now rounded to the nearest pixel. [#2492] - Fixed the ``find_peaks`` ``min_separation`` keyword (also used by the star finder classes) so that the local region is a circle of that radius centered on each pixel. Previously, the region was larger than the circle for radii smaller than 2 pixels (e.g., diagonal neighbors were suppressed for ``min_separation=1``) and was offset by half a pixel for most noninteger radii. Small radii are also faster. [#2493] - Fixed the ``DAOStarFinder`` ``flux`` to be the sum of the data values within the kernel footprint, as documented. Previously, it was the sum over the entire rectangular kernel array, which includes pixels beyond the truncation radius of the Gaussian kernel. The ``flux`` values are therefore smaller, the ``mag`` values are larger, and the sources selected by ``n_brightest`` may differ. [#2493] - Fixed ``find_peaks`` (and the star finder classes, which use it) so that masked pixels can no longer suppress nearby peaks within the local region. Masked pixels are now replaced by the minimum finite data value before peak detection, the same treatment already applied to NaN pixels. Previously, a masked pixel (e.g., a flagged cosmic ray or hot pixel) with a value larger than a nearby true peak prevented that peak from being detected. [#2377] - ``StarFinder`` now validates the input ``kernel`` values. The kernel must contain only finite values and have a positive maximum. [#2377] - Fixed the ``DAOStarFinder`` ``roundness1`` values for a non-square kernel, which can occur for an elliptical kernel (``ratio < 1``). As in DAOFIND, ``roundness1`` is now computed over the central square of the kernel array. Previously, it was computed over the entire rectangular kernel array. The sources selected by ``roundness_range`` may therefore differ for such kernels. Square kernels are not affected. [#2510] - ``DAOStarFinder`` now rejects sources whose centroid is outside of the image, as in DAOFIND. Such sources could previously be returned for peaks in the pixels at the image edges. [#2510] - ``IRAFStarFinder`` now rejects sources with fewer than 5 positive sky-subtracted pixels within the kernel footprint by default, as in IRAF's starfind. Previously, only 2 such pixels were required, so fewer faint sources may be returned. Set ``min_n_pixels=2`` to recover the previous behavior. [#2510] - ``IRAFStarFinder`` now rejects sources whose centroid is outside of the image, as in IRAF's starfind. [#2510] - ``photutils.isophote`` - Changed ``bool`` to ``bint`` in ``_ellipse_model.pyx`` to fix a compilation issue with Cython 3.1.x. [#2260] - Fixed spurious NumPy "Mean of empty slice" and related warnings raised during isophote fitting when a trial or failed isophote has an elliptical path that falls entirely (or largely) outside the image. [#2283] - Fixed ``EllipseSample`` not resetting ``EllipseGeometry`` cached attributes when overriding ``sma``. [#2280] - Fixed a compilation issue in ``_ellipse_model.pyx`` with Cython 3.3, which no longer allows tuples of typed memoryviews. [#2404] - Fixed an issue when fitting isophotes with the ``'nearest_neighbor'`` integration mode. At small semimajor axes, nearest-neighbor sampling could measure a local intensity gradient of exactly zero, and the resulting division by zero corrupted the ellipse geometry. [#2405] - ``photutils.morphology`` - Fixed ``data_properties`` so that a ``background`` input with units is preserved. Previously, the units were stripped, and a confusing units-mismatch error was raised when ``data`` was a ``Quantity``. [#2370] - Fixed ``data_properties`` and ``gini`` to use the mask of a ``numpy.ma.MaskedArray`` input. Previously, the mask was ignored and the masked values were included in the calculations. [#2499] - ``photutils.profiles`` - Fixed ``RadialProfile.data_profile`` so that its values are always consistent with the current profile normalization. Previously, if ``normalize`` was called before ``data_profile`` was first accessed, the raw (unnormalized) values were returned, and a subsequent ``unnormalize`` call then incorrectly multiplied those raw values by the normalization. [#2371] - Fixed ``RadialProfile.data_profile`` to return a ``Quantity`` when the input data is a ``Quantity``, consistent with the ``profile`` attribute. [#2371] - Fixed the ``RadialProfile`` ``data_radius`` and ``data_profile`` attributes so that pixels whose centers lie exactly at the maximum input radius are included on all sides of the center. Previously, such pixels were excluded on the upper-x and upper-y sides of the center. [#2371] - Fixed the profile ``plot`` method so that the y-axis label no longer includes the data unit after the profile has been normalized (a normalized profile is dimensionless). [#2371] - Fixed the ``RadialProfile`` ``gaussian_fit`` and ``moffat_fit`` attributes for ``Quantity`` input data. Previously, a ``UnitsError`` was raised for an unnormalized profile and the model amplitude had inverse data units for a normalized profile. [#2494] - The profile classes now raise a ``ValueError`` for an invalid ``method`` or ``subpixels`` value, or for ``data`` that is not 2D, when the class is created. Previously, the error was raised only when the profile was first computed. [#2494] - The profile ``normalize`` method now emits a warning and leaves the profile unchanged when the maximum or sum of the profile is negative. Previously, the profile was divided by the negative value, which reversed its sign. [#2494] - The ``RadialProfile`` ``data_radius`` and ``data_profile`` attributes now exclude pixels whose centers lie inside the minimum input radius, consistent with the binned profile. Previously, only the maximum input radius was applied. [#2494] - A ``Quantity`` ``radii`` (or ``half_sizes``) input to the profile classes now raises a ``TypeError``. Previously, the units were silently dropped. [#2371] - The ``profile_error`` values of the profile classes are now slightly smaller because the aperture photometry flux error propagation now weights each pixel variance by the squared aperture overlap fraction. [#2398] - ``photutils.psf`` - Fixed a bug where evaluating an ``AiryDiskPSF`` model with an array-valued ``flux`` at input coordinates with fewer dimensions than the ``flux`` array raised a ``ValueError``. [#2473] - Fixed a bug where calling ``PSFPhotometry`` with an ``init_params`` table containing a ``group_id`` column permanently disabled the grouper for all subsequent calls on the same instance. [#2383] - Fixed a bug where nonfinite data values were not merged into a user-provided mask, causing fit failures in ``fit_2dgaussian``, ``fit_fwhm``, and ``PSFPhotometry``. [#2383] - Fixed a bug where grouped fits could silently reuse a stale cached flat-model class when multiple PSF models of the same class (e.g., two different ``ImagePSF`` models) were used, producing incorrect photometry. The flat-model class cache is now stored per fitter instance. [#2384] - Fixed a bug where PSF model parameter bounds were not applied in grouped fits, so grouped fits were unconstrained where single-source fits were constrained. The bounds are now also propagated back to the fitted models, so the ``PARAMETER_NEAR_BOUND`` flag is now set for grouped sources. [#2384] - Fixed a bug where ``PSFPhotometry`` raised an error for integer-dtype data when a ``local_bkg`` column was provided. [#2384] - An ``init_params`` table column that has a unit attribute (e.g., from a ``Table`` instead of a ``QTable``) is now converted to the data unit instead of raising an error claiming the column has no units. The error message for a truly unitless column now suggests using a ``Quantity`` column. [#2384] - Fixed a bug where ``EPSFBuilder`` rejected all ``LinkedEPSFStar`` inputs as invalid stars, a regression introduced in version 3.0. [#2385] - Fixed a bug where the ``EPSFBuilder`` star exclusion warning raised a ``TypeError`` for stars with the default (`None`) or string ``id_label`` values. The warning message now always reports the flat star index and includes the id label when available. [#2385] - Fixed a bug where the ``EPSFStar`` ``slices`` and ``bbox`` attributes were transposed for nonsquare cutouts. [#2386] - Fixed a bug where the ePSF building flux renormalization used stale cached values, so every iteration normalized the star data with the first-iteration flux instead of the updated fitted flux. [#2386] - Fixed a bug where the ``n_pixels_fit`` results column leaked into the tables returned by the ``PSFPhotometry`` ``results_to_init_params`` and ``results_to_model_params`` methods (as ``n_pixels_init`` and ``n_pixels`` columns) and into the deprecated ``fit_params`` table. [#2387] - ``PSFPhotometry`` now raises a ``ValueError`` if the ``init_params`` table contains duplicate ``id`` values. Previously, duplicate ids silently produced a results table with extra mispaired rows. [#2387] - Fixed a bug where ``PSFPhotometry.finder_results`` retained the results from a previous call when a subsequent call provided ``init_params`` (so the finder was not used). [#2387] - ``PSFPhotometry`` now raises a ``ValueError`` if the ``init_params`` table has no rows. [#2387] - Fixed a bug where the ``PSFPhotometry`` ``fit_info`` list was not reordered to match the results table when the ``init_params`` table was not sorted by the ``id`` column. [#2387] - Fixed a bug where the ``reduced_chi2`` calculation emitted a divide-by-zero warning when the number of fit pixels equaled the number of fit parameters. NaN is now returned without a warning when there are no degrees of freedom. [#2387] - The ``PSFPhotometry`` ``group_warning_threshold`` keyword is now validated to be a positive integer. [#2387] - Fixed a bug where ``IterativePSFPhotometry`` with ``mode='all'`` raised an error when an earlier iteration produced a source with nonfinite fit results. Such sources are now dropped from later iterations. [#2387] - Fixed a bug where ``IterativePSFPhotometry`` with ``mode='all'`` did not carry the local background values into iterations after the first, so sources were refit with a local background of zero. [#2387] - Fixed a bug where a callable ``finder`` returning a zero-row table caused an error in ``IterativePSFPhotometry``. The iteration now stops cleanly. [#2387] - Fixed a bug where the ``IterativePSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods raised a confusing error after a run in which no sources were detected. They now raise a clear ``ValueError``. [#2387] - The ``IterativePSFPhotometry`` ``sub_shape`` keyword is now validated at initialization to have odd, positive values, matching the documented requirement. The ``maxiters`` keyword validation now also rejects nonnumeric and boolean inputs. [#2387] - Fixed a bug where the ``GaussianPSF`` and ``GaussianPRF`` bounding boxes treated a float ``theta`` value (in degrees) as radians, producing incorrect bounding-box extents for rotated models. [#2388] - Fixed a bug where ``AiryDiskPSF`` returned the peak value instead of NaN for NaN input coordinates. [#2388] - Fixed the default lower bound of the ``MoffatPSF`` ``beta`` parameter, which rounded to exactly 1.0 instead of being strictly greater than 1. [#2388] - ``CircularGaussianPSF`` and ``CircularGaussianPRF`` now raise a ``UnitsError`` during fitting when the x and y inputs have different units, consistent with the other Gaussian models. [#2388] - Fixed the normalization prefactor shown in the ``AiryDiskPSF`` docstring and documented that the rotated ``GaussianPRF`` pixel integration is performed along the rotated principal axes of the Gaussian. [#2388] - Fixed ``stdpsf_reader`` and ``STDPSFGrid`` to accept an already-open ``astropy.io.fits.HDUList`` or file object. Such inputs were previously accepted by the low-level reader but then raised a ``TypeError`` when parsing the metadata. [#2347, #2388] - Fixed ``webbpsf_reader`` to correctly read files containing a single 2D PSF. The data was previously reshaped with the new axis in the wrong position, raising a ``ValueError``. [#2388] - ``GriddedPSFModel`` now raises a ``ValueError`` if ``grid_xypos`` contains duplicate positions. Previously, duplicate positions could silently produce an invalid grid. [#2388] - Fixed ``GriddedPSFModel.copy`` so that the copy has its own ``meta`` dictionary. Previously, setting the ``oversampling`` on a copy also changed the ``meta`` dictionary of the original model. [#2388] - Fixed ``plot_grid`` for integer-dtype ePSF grids, which raised a casting error with ``deltas=True`` or ``peak_norm=True``. Also, with both ``deltas=True`` and ``peak_norm=True``, the differences are now normalized by the average ePSF peak, as documented, instead of the maximum difference value. [#2388] - Fixed ``GriddedPSFModel.evaluate`` and ``ImagePSF.evaluate`` to accept scalar coordinates when called directly. [#2388] - Fixed ``GriddedPSFModel.read`` to raise the standard format identification error for a corrupt file with a FITS extension instead of an ``OSError``. [#2388] - Fixed a bug where ``SourceGrouper`` raised an error for scalar x and y inputs when ``return_groups_object=True``. 2D coordinate arrays now raise a clear ``ValueError``, and ``SourceGroups`` now validates that the input coordinates are finite. [#2389] - Fixed a bug where the interpolation of masked pixels in ePSF building raised an error for integer-dtype star data. The interpolation is now always performed in float. [#2389] - Fixed a bug in ``make_psf_model_image`` where the ``flux`` keyword was silently ignored for models whose flux parameter has a different name (e.g., models output from ``make_psf_model``). The keyword is now mapped to the model's flux parameter name. [#2389] - Fixed a bug where the ``EPSFStars`` star counts and flat star list were stale after deleting a star from the container. [#2390] - Fixed a bug where ``extract_stars`` could modify a user-supplied weights uncertainty array when masking pixels. [#2390] - ``extract_stars`` no longer requires every input image to have a WCS when multiple catalogs are input. A WCS is now required only for images whose catalog provides only sky coordinates. [#2390] - ``EPSFStar`` now raises a ``TypeError`` for ``Quantity`` input data. Previously, ``Quantity`` data produced confusing downstream errors. [#2390] - Fixed a bug where an even ``EPSFBuilder`` ``shape`` value raised an error instead of being made odd by adding one (with a warning), as documented. [#2390] - Fixed the ``EPSFBuilder`` minimum-shape validation to match the automatically derived ePSF shape. Previously, a ``shape`` equal to the derived shape could be rejected as too small when the star size times the oversampling factor was odd. [#2390] - ``EPSFBuilder`` now raises a ``ValueError`` if the initial ePSF model passed to ``build_epsf`` has an oversampling that does not match the builder oversampling. [#2390] - Fixed a bug where a fitter that raised a ``TypeError`` during ePSF fitting was silently retried without weights. Whether the fitter supports weights is now determined from its call signature. [#2390] - Fixed a bug where the ``make_model_image`` and ``make_residual_image`` methods raised an error with ``include_local_bkg=True`` when no local background values were available. The local background is now treated as zero. [#2391] - Fixed a bug where the initial flux estimates in ``fit_2dgaussian`` and ``fit_fwhm`` included pixels masked by the ``mask`` keyword, corrupting the fit starting point. [#2395] - Fixed a bug where interpolating missing data during ePSF building raised a ``QhullError`` when fewer than three valid pixels remained or all valid pixels were collinear. Nearest-neighbor interpolation is now used as a fallback. [#2395] - Fixed a bug where the ``parameter_near_bound`` flag (bit 32) was only set for bounds applied via the ``xy_bounds`` keyword. The flag is now set when any fitted parameter is very close to its bounds, including bounds set directly on the PSF model. [#2395] - ``AiryDiskPSF`` now evaluates to zero at infinite radial distance, matching the other PSF models. Previously, it returned NaN. [#2395] - Fixed the ``CircularGaussianPSF.fit_deriv`` partial derivative with respect to ``fwhm``. This affected only fits in which the ``fwhm`` parameter was unfixed. [#2347] - Fixed ``ImagePSF`` to discard its cached interpolator when the ``data`` attribute is set. Previously, assigning a new image to an existing model silently continued to evaluate the old image. The ``data`` and ``oversampling`` attributes are now also validated when set, not only when the model is created. [#2347] - Fixed the ``GriddedPSFModel.origin`` attribute, which returned a three-element array instead of the documented ``(x, y)`` pair. The spurious third element was derived from the number of ePSFs in the grid. Model evaluation was unaffected because it used only the first two elements. [#2347] - Fixed the ``filter`` metadata parsed from the filename when reading a gzipped STDPSF file (e.g., ``.fits.gz``). The file extension was not removed, so the filter name included the extension. [#2347] - Fixed ``webbpsf_reader`` to swap the ``DET_YX{i}`` FITS header values when defining ``grid_xypos``. The reference ePSFs were previously swapped to the wrong detector positions for any grid whose x and y positions differ. [#2347] - Fixed ``SourceGroups.plot`` to sample a user-supplied colormap over its full range. Previously the colormap was indexed by the group number, which gave nearly identical colors for every group (and raised an ``IndexError`` for more than 256 groups). [#2347] - Fixed the ``plot_grid`` title when the instrument, detector, or filter metadata is missing. The missing values left stray spaces in the title. [#2347] - Fixed an all-zero row and column at one edge of the ePSF built by ``EPSFBuilder``. The recentering step shifts the ePSF with its spline, and the edge that moved outside the grid was filled with zero instead of being extrapolated. [#2418] - Fixed a checkerboard pattern in ePSFs built by ``EPSFBuilder`` with ``oversampling`` greater than one from heterogeneous or contaminated stars. Each star pixel residual is now deposited on the oversampled grid points within 0.375 pixel (and at least one grid spacing) of the pixel center, so that every star contributes to both parities of the grid regardless of its subpixel phase, and structure that repeats with a period of about one input pixel or shorter is removed from the ePSF in each iteration. A warning is now emitted if the subpixel phases of the fitted star centers are strongly nonuniform, which indicates biased star centers. [#2419, #2465] - Fixed a bug where ``EPSFBuilder`` raised ``NonFiniteValueError`` when a star cutout contained a nonfinite (e.g., NaN) pixel inside the fitting region. A star whose fitting region is fully masked is now treated as a fit failure instead of raising an error. [#2423] - Fixed the ``copy`` methods of ``ImagePSF`` and ``GriddedPSFModel`` so that the copies no longer share the parameter array, the constraints, and the constraints cache that astropy keeps on each model instance. Copies fitted at the same time in several threads interfered with each other. The parameters of a copy now also refer to the copy instead of the original model. [#2453] - ``GriddedPSFModel`` now computes the bilinear interpolation weights of the grid PSFs in double precision when ``grid_xypos`` has a single-precision dtype. The weights were computed in single precision, which limited the model values to about 8 significant digits. [#2455] - Fixed ``PSFPhotometry``, ``IterativePSFPhotometry``, and ``extract_stars`` raising a ``ValueError`` for a source whose fit region or cutout ends exactly at the lower edge of the image. Such sources do not overlap the image and are now flagged or skipped. [#2484] - Fixed ``PSFPhotometry`` and ``IterativePSFPhotometry`` returning zero for the fixed model parameters of grouped sources. [#2508] - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now raise a ``ValueError`` with a clear message if all the parameters of the PSF model are fixed. [#2508] - The ``qfit`` and ``cfit`` values of ``PSFPhotometry`` and ``IterativePSFPhotometry`` are now ``0.0`` instead of ``-0.0`` for an exact fit. [#2509] - Fixed ``LinkedEPSFStar.constrain_centers`` computing wrong star centers for a WCS whose latitude axis comes first (e.g., ``CTYPE1 = 'DEC--TAN'``). [#2508] - Fixed ``extract_stars`` raising a ``TypeError`` for an ``NDData`` uncertainty that cannot be converted to a standard deviation (e.g., ``UnknownUncertainty``, which ``NDData`` creates for a plain array). Such an uncertainty is now assumed to be a standard deviation. [#2508] - Fixed ``stdpsf_reader`` setting the ``instrument`` and instrument detector name in the ``meta`` of the returned ``GriddedPSFModel`` only when a ``detector_id`` was input. For a file with a single detector, the metadata no longer depends on ``detector_id``, and ``meta['detector']`` is now the detector name (e.g., ``'A1'``) instead of the filename field (e.g., ``'NRCA1'``). ``STDPSFGrid`` sets the same metadata. [#2508] - Fixed ``fit_2dgaussian`` and ``fit_fwhm`` raising a ``NoOverlapError`` for a position whose fit region lies completely outside the image. Such a source is now flagged in the ``fit_2dgaussian`` results and has a NaN ``fit_fwhm`` value. [#2508] - Fixed the aperture-based initial flux of ``PSFPhotometry`` and ``IterativePSFPhotometry`` when a local background is used. The per-pixel ``local_bkg`` value was subtracted once from the aperture sum. It is now multiplied by the aperture area first. For sources found in a later iteration of ``IterativePSFPhotometry`` with ``mode='all'``, the local background was not subtracted at all. Only the initial guess (the ``flux_init`` column) changes. [#2508] - ``photutils.psf_matching`` - ``make_wiener_kernel`` now validates a custom ``penalty`` array. It must be 2D with odd dimensions in both axes, contain only finite values, and not be all zeros. [#2372] - ``make_kernel`` and ``make_wiener_kernel`` now raise a ``ValueError`` if the computed kernel contains nonfinite values, which can occur when the Fourier-space denominator is zero at frequencies where the numerator is also zero. [#2372] - ``make_wiener_kernel`` now raises a ``ValueError`` stating that ``regularization`` must be positive when it is NaN. Previously, an unrelated error about a nonfinite kernel was raised. [#2494] - Fixed the window classes for nonsquare shapes. The window radii are now defined relative to the size of the array along each axis, so the window is elliptical instead of circular. Previously, a window passed to ``make_kernel`` or ``make_wiener_kernel`` with nonsquare PSFs cut off lower spatial frequencies along the longer axis, which made the matching kernel too narrow along that axis. Windows for square shapes are unchanged. [#2495] - Fixed ``resize_psf`` to return a PSF with the requested pixel scale. The output pixel scale was ``input_pixel_scale * (n_in - 1) / (n_out - 1)``, where ``n_in`` and ``n_out`` are the input and output sizes, which made the resized PSF too broad or too narrow by up to several percent. The output size is now the largest odd size for which the output grid lies within the outermost pixel centers of the input PSF, which can be smaller than before. [#2474] - ``resize_psf`` now raises a ``ValueError`` if ``order`` is not an integer from 0 to 5. The exception was a ``RuntimeError`` or a ``TypeError``, depending on the value. [#2508] - ``photutils.segmentation`` - Fixed ``SourceCatalog.orientation`` to return a value in the range (-90, 90] degrees instead of incorrectly wrapping it to [0, 360) degrees (which only ever produced values in the disjoint ranges [0, 90] and (270, 360)). This also makes it consistent with ``ApertureStats.orientation``. [#2317] - ``SegmentationImage.remove_border_labels`` now raises a ``ValueError`` for nonpositive ``border_width`` values. Previously, ``border_width=0`` (and negative widths) silently removed every label because the entire array was treated as the border region. [#2378] - Fixed ``SourceCatalog`` slicing so that the sliced catalog no longer shares its custom-properties list (and other mutable containers, such as ``meta``) with the parent catalog. [#2378] - Fixed ``SourceCatalog.segment_flux`` so that the local-background subtraction uses the number of unmasked pixels actually summed. Previously, with a ``detection_catalog`` input and a different mask, the local background was multiplied by the detection catalog's ``area``, oversubtracting the background. [#2378] - ``deblend_sources`` with ``contrast=1`` (no deblending) now honors ``relabel=True``, relabeling nonconsecutive input labels like every other code path. [#2378] - ``SourceCatalog.add_property`` now raises a ``ValueError`` for names of built-in methods (e.g., ``to_table``). [#2378] - Fixed the ``SourceCatalog`` ``kron_flux_err`` and ``circular_photometry`` flux errors so that each pixel variance is weighted by the squared aperture overlap fraction. [#2398] - Fixed ``SourceCatalog.background_centroid`` being calculated in ``float32`` precision for a ``float32`` input ``background`` array. It is now calculated in ``float64``, as are all of the other source properties. [#2446] - Fixed a crash in ``deblend_sources`` when the input ``SegmentationImage`` has a ``uint64`` data type. [#2462] - Fixed ``SegmentationImage`` and ``deblend_sources`` running out of memory for a 2D segmentation image with very large label values (e.g., ``2**40``). The ``slices``, ``bbox``, and ``segments`` attributes, the ``relabel_consecutive``, ``reassign_labels``, ``keep_labels``, and ``remove_labels`` methods, and the relabeling in ``deblend_sources`` allocated memory in proportion to the largest label value. They now use memory in proportion to the number of labels. [#2464] - Fixed a ``ValueError`` when getting the ``slices`` attribute of a zero-size ``SegmentationImage``. An empty list is now returned. [#2464] - Fixed the ``SourceCatalog`` ``sky_bbox_ul``, ``sky_bbox_lr``, and ``sky_bbox_ur`` properties, which were one pixel beyond the bounding box along its upper and right edges. [#2502] - Fixed ``SourceCatalog.select_label`` and ``select_labels`` returning the wrong source or raising an ``IndexError`` for a label that is in the segmentation image but not in a sliced catalog. A ``ValueError`` is now raised. [#2503] - Fixed ``SourceCatalog.flux_radius`` returning the radii of the ``detection_catalog`` when one was input. The radius is now measured in the catalog ``data`` as a fraction of its own Kron flux. [#2503] - Fixed the ``SourceCatalog`` quadratic centroid setting the pixels of its 3x3 fitting box that are outside of the source segment to zero, which biased the centroid of a source whose peak pixel is next to the edge of its segment. The unmasked pixels of the box, including the pixels of neighboring sources, are now fit with their ``data`` values. [#2503] - Fixed the Kron flags and flux error of a ``SourceCatalog`` source that is completely masked in the catalog but not in its ``detection_catalog``. Its ``kron_flux_err`` is now NaN and the ``kron_undefined`` flag is set instead of ``kron_non_finite_data``. [#2503] - Fixed the ``SegmentationImage`` ``deblended_labels``, ``deblended_label_to_parent``, and ``parent_to_deblended_labels`` attributes keeping removed deblended labels as label 0 and listing merged deblended labels more than once. A label merged from the deblended labels of more than one parent is kept under each of its parents. [#2503] - Fixed ``deblend_sources`` with the ``'basin'`` contrast method including NaN pixels in the flux of a deblended source, which made that flux NaN and changed which sources passed the contrast criterion. [#2503] - Fixed ``deblend_sources`` with ``contrast=1`` (no deblending) returning the ``info``, ``flags``, and deblended labels of an input segmentation image that was itself deblended, and not validating the input ``labels``. [#2503] - ``photutils.utils`` - Fixed a thread-safety issue in ``ImageDepth`` by using a copy of the user's ``SigmaClip`` instance. [#2364] - Fixed the ``orientation`` and ``sky_orientation`` properties of ``SourceCatalog`` and ``ApertureStats`` returning -90 degrees, outside of the documented (-90, 90] range, for a source with a negative-zero covariance. [#2500] - Fixed ``ImageDepth`` so that repeated calls on the same instance no longer accumulate the ``fluxes`` attribute across calls. The result attributes now always reflect only the most recent call. [#2375] - Fixed a crash in ``calc_total_error`` when the input ``data`` array has an integer data type. [#2375] - The local WCS Jacobians and pixel scales used by the aperture ``to_sky`` and ``to_pixel`` methods and by the ``SourceCatalog`` sky position errors are now computed with central finite differences (half a pixel on either side of the position) instead of one-sided 1-pixel differences. [#2425] - The WCS helper functions now evaluate a ``gwcs`` transform with its bounding box disabled. Previously, sources within half a pixel of the array edge received NaN Jacobians and pixel scales because the finite-difference offsets fell outside the bounding box. [#2425] - Fixed ``CutoutImage`` raising a ``ValueError`` instead of a ``NoOverlapError`` when ``shape`` is a NumPy array and the cutout ends exactly at the lower edge of the data. [#2484] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``PixelAperture.do_photometry`` method is now deprecated and will be removed in version 4.0. Use the new ``AperturePhotometry`` class instead. [#2324, #2328] - The ``ApertureStats`` ``inertia_tensor`` property is deprecated and will be removed in version 4.0. It carries no information beyond the ``covariance`` matrix. [#2500] - The default ``ApertureStats.to_table()`` columns now include a ``'flags'`` column. [#2327, #2379] - The ``ApertureStats.ids`` attribute is now deprecated and will be removed in version 4.0. Use the ``ApertureStats.id`` attribute instead. [#2331] - The ``ApertureStats.n_apertures`` attribute is now deprecated and will be removed in version 4.0. Use the new ``ApertureStats.n_positions`` attribute instead, which matches ``AperturePhotometry.n_positions``. [#2362] - ``AperturePhotometry`` and ``ApertureStats`` now validate the ``method``/``sum_method`` and ``subpixels`` keywords when the object is created instead of when a measured attribute is first accessed. [#2348] - ``ApertureStats`` now stores the ``segmentation_image``, ``labels``, and ``mask_method`` inputs as public attributes, matching ``AperturePhotometry``. [#2348] - The ``'aperture_photometry_args'`` metadata in the ``AperturePhotometry.to_table()`` output now also records the ``mask_method`` keyword. [#2348] - ``BoundingBox.__eq__`` now returns `NotImplemented` instead of raising a ``TypeError`` when the other object is not a ``BoundingBox``, so that comparing a ``BoundingBox`` to another type returns `False` rather than raising. [#2348] - The ``mask`` input to aperture photometry and the aperture ``area_overlap`` method must now be a boolean array (or `None`). [#2362] - The ``PixelAperture.plot`` method now returns a list of patches for scalar apertures, as documented, instead of a tuple. [#2362] - The ``to_pixel`` and ``to_sky`` methods of the circular apertures now use the geometric mean of the two singular values of the local WCS Jacobian (the square root of its absolute determinant) as the isotropic pixel scale for every WCS. The scale preserves the area of the mapped circle and inverts exactly, so a conversion to pixels and back returns the original radius. Previously, a distorted WCS or a ``gwcs`` used the arithmetic mean of the singular values, and an undistorted ``astropy.wcs.WCS`` used the geometric mean of the x and y pixel scales. The radii are unchanged for an undistorted WCS with orthogonal pixel axes. [#2425] - The ``ApertureStats`` ``covariance`` is now regularized by raising each eigenvalue to at least ``1/12`` pixel**2 instead of adding ``1/12`` to both diagonal elements when the determinant is below ``(1/12)**2``. Thin sources that are unresolved along only their minor axis are now regularized, the variance along a resolved axis is no longer inflated, and the result is continuous. This affects the covariance-derived shape properties of unresolved sources (those with the ``singular_covariance`` flag). An exactly thin tilted source, whose covariance determinant is zero apart from rounding, is now regularized instead of having NaN shape properties. [#2437] - ``photutils.datasets`` - The ``load_irac_psf`` and ``load_simulated_hst_star_image`` functions are now deprecated and will be removed in version 4.0. [#2476] - ``make_model_image`` now also skips sources that have a nonfinite ``'local_bkg'`` value. Previously, a nonfinite local background was added to the source region, propagating NaN or inf values into the output image. [#2374] - ``make_model_image`` now determines the output image units up front from the parameter table column units, so the output units no longer depend on whether any sources are actually rendered (e.g., a zero-row table or sources that are skipped or do not overlap the image). [#2374] - ``make_model_image`` now validates the ``discretize_method`` keyword and the values in a ``'model_shape'`` column (which must be finite and positive) up front with clear error messages. Previously, invalid values raised cryptic errors from within the source loop or silently rendered nothing. [#2374] - The ``make_model_image`` ``shape`` argument now also accepts a single integer, which is used for both dimensions, consistent with ``make_model_params``. [#2374] - The ``make_model_params`` output table now includes the creation date and photutils version metadata, consistent with ``make_random_models_table``. [#2374] - ``make_wcs`` with ``galactic=True`` now returns a WCS with a tangent (TAN) projection instead of a plate carree (CAR) projection, so that it is equivalent to the ``make_gwcs`` WCS. [#2499] - ``make_model_params`` now raises a ``ValueError`` if a keyword argument is named ``id`` or has the same name as ``x_name`` or ``y_name``, and ``make_random_models_table`` now raises a ``ValueError`` if ``param_ranges`` contains an ``'id'`` key. Previously, the ID or position column was silently replaced. [#2499] - ``make_model_image`` now raises a ``ValueError`` with a clear message if a ``params_table`` column has units and ``params_table`` is not a ``QTable``. [#2499] - ``photutils.detection`` - The ``repr`` and ``str`` output of ``StarFinderCatalogBase`` now labels the number of sources as ``n_sources`` instead of ``nsources`` for naming consistency. [#2338] - The star-finder cutout centers used for the shape and flux measurements now round half-integer pixel positions half away from zero instead of half to even, consistent with the rounding convention used elsewhere in photutils. [#2375] - The ``find_peaks`` ``min_separation`` keyword (also used by the star finder classes) now returns only one peak for pixels with identical values that are within ``min_separation`` of each other. A group of connected equal-valued peak pixels (e.g., a saturated star) is replaced by its pixel nearest to the centroid of the group, and only one of any remaining equal-valued peaks that are separated by ``min_separation`` or less is kept. All returned peaks are now separated by more than ``min_separation`` pixels. Previously, all pixels that were tied for the maximum were returned. Use a circular ``footprint`` for the previous behavior. [#2506] - ``photutils.geometry`` - The ``circular_overlap_grid``, ``elliptical_overlap_grid``, and ``rectangular_overlap_grid`` functions now validate the ``use_exact`` and ``subpixels`` inputs, raising a ``ValueError`` for values outside the documented ranges. [#2369] - ``photutils.profiles`` - The radius methods of the curve-of-growth classes (``calc_radius_at_ee`` of ``CurveOfGrowth`` and ``EllipticalCurveOfGrowth``, and ``calc_half_size_at_ee`` of ``EnsquaredCurveOfGrowth``) now return the smallest radius at which the interpolated profile reaches the given energy, for every energy in the profile range. They are now consistent with the energy methods (``calc_ee_at_radius`` and ``calc_ee_at_half_size``). Previously, they used only the part of a noisy profile before its first decrease, returned NaN for larger energies, and raised a ``ValueError`` if the profile did not increase at its smallest radii. [#2496] - The encircled energy methods of the curve-of-growth classes now return a ``Quantity`` when the input data has units, and the radius methods accept a ``Quantity`` energy. The encircled energy methods now raise a ``TypeError`` for a ``Quantity`` radius. Previously, its units were silently dropped. [#2496] - ``photutils.psf`` - The default ``recentering_func`` of ``EPSFBuilder`` is now ``centroid_symmetry`` instead of ``centroid_com``. The ePSF is centered on the point about which its core is most symmetric, which is the definition of Anderson 2016, instead of its center of mass. For an asymmetric PSF the star positions fit with the built ePSF change by a constant offset, up to about 0.1 pixel for undersampled space-telescope PSFs. The center of mass is still used if the symmetry center cannot be calculated, and a warning is emitted if the recentering box is too small for it. Use ``recentering_func=centroid_com`` for the previous behavior. [#2488] - The convergence of an ``EPSFBuilder`` build is now measured from the changes of the star centers relative to their median change, which must also be less than ``center_accuracy``. A change that is common to all the stars is a shift of the ePSF center, and one that is smaller than ``center_accuracy`` no longer delays the convergence. The ``final_center_accuracy`` attribute and the ``max_center_shift`` column of ``iteration_info`` are the relative changes, and the new ``common_center_shift`` column is the median change. [#2488] - ``GaussianPRF`` is now integrated exactly over the detector pixels for any rotation angle. Previously, the integration was performed along the rotated principal axes of the Gaussian, which was exact only for ``theta = 0``. The model values change for a rotated Gaussian with different x and y widths, by up to about 1% of the peak for a FWHM of 1 pixel. Such models are about four times slower to evaluate on a pixel grid. [#2471] - The ``PSFPhotometry.decode_flags`` and ``IterativePSFPhotometry.decode_flags`` methods now return a dictionary mapping each source id from the results table to its list of active flag names (or bit values), instead of a positional list of lists. This is a backward-incompatible change, made so that decoded flags can be looked up directly by source id (which may not be sequential when custom ids are input) and for consistency with the new aperture and segmentation ``decode_flags`` methods. The module-level ``decode_psf_flags`` function is unchanged and still returns lists. [#2400] - The ``make_psf_model`` function is now deprecated and will be removed in version 4.0. Use one of the PSF models provided by ``photutils.psf``, an ``ImagePSF`` made from the output of ``make_epsf_from_psf``, or a custom astropy ``Fittable2DModel`` with ``x_0``, ``y_0``, and ``flux`` parameters instead. [#2478] - Defining a custom interpolator for ``ImagePSF``, either by overriding the ``interpolator`` attribute in a subclass or by assigning an interpolator to it on a model, is now deprecated and will be removed in version 4.0. A model with a custom interpolator calls it as before and does not use the compiled spline kernel. [#2456] - The ``EPSFBuildResult`` class returned by ``EPSFBuilder`` has been renamed to ``EPSFBuildResults``. The old ``EPSFBuildResult`` name is deprecated and will be removed in version 4.0. [#2326] - The default convergence criterion of ``EPSFBuilder`` has changed. The build now converges when at least 95% of the successfully fitted stars (the new ``converged_fraction`` keyword) have met the ``center_accuracy``, instead of all of them, so the same inputs may now stop after fewer iterations with ``converged=True``. Set ``converged_fraction=1.0`` for the previous behavior. [#2422] - The ``nxpsfs`` and ``nypsfs`` metadata keys of the ``GriddedPSFModel`` returned when reading a STDPSF file have been removed. They were redundant with the ``grid_shape`` attribute, which contains the same information as ``(nypsfs, nxpsfs)``. [#2344] - The ``'grid_shape'`` key has been removed from the ``GriddedPSFModel`` ``meta`` dictionary. Use the new ``GriddedPSFModel.grid_shape`` attribute instead. [#2360] - The ``grid_xypos``, ``oversampling``, and ``grid_shape`` keys have been removed from the ``STDPSFGrid`` ``meta`` dictionary. The ``meta`` dictionary now contains only the metadata parsed from the filename (e.g., ``STDPSF``, ``detector``, and ``filter``). Use the ``grid_xypos``, ``oversampling``, and ``grid_shape`` attributes instead. [#2344] - The ``STDPSFGrid`` ``grid_xypos`` attribute is now a ``numpy`` array instead of a list, matching the ``GriddedPSFModel`` attribute of the same name. The ``oversampling`` attribute is now a read-only property. [#2344] - The ``GriddedPSFModel`` ``meta`` dictionary now always stores ``'grid_xypos'`` as the sorted array of grid positions and ``'oversampling'`` as a normalized ``(y, x)`` tuple, so that both always match the attributes of the same name. Previously, these keys held the input values unchanged, which could differ from the attributes in ordering, type, or shape. [#2344] - The ``repr`` and ``str`` output of ``GriddedPSFModel``, ``ImagePSF``, and ``STDPSFGrid`` now show the oversampling as a tuple (e.g., ``(4, 4)``) instead of a list or ``numpy`` array. The ``STDPSFGrid`` output now labels the grid shape as ``Grid shape`` instead of ``Grid_shape``. [#2344] - ``decode_psf_flags`` now raises a ``TypeError`` for boolean flag values instead of treating `True` as bit value 1. [#2375] - The ``EPSFBuilder`` ``coord_transformer`` attribute is now deprecated and will be removed in a future version. It is an internal helper object that was never intended to be public. [#2390] - ``EPSFBuilder`` now supports ``sigma_clip=None`` to disable sigma clipping when stacking the ePSF residuals, as its docstring already documented. [#2390] - The ``mask`` and ``error`` keywords of ``PSFPhotometry`` and ``IterativePSFPhotometry`` now raise a ``ValueError`` when the input data is an ``NDData`` instance. Previously, they were silently ignored in favor of the ``NDData`` attributes. ``NDData`` input is now also documented in the ``__call__`` docstrings. [#2395] - The error message raised for nonpositive or nonfinite ``error`` values within a fit region now includes the location of the fit region. [#2395] - ``EPSFStars`` now explicitly supports array conversion (e.g., ``np.asarray`` or passing it to matplotlib's ``imshow``). A container holding a single star (e.g., from indexing) converts to that star's 2D cutout, and a container holding multiple stars converts to a 3D stack of the cutouts. [#2395] - The default ``smoothing_kernel`` and ``fit_shape`` of ``EPSFBuilder`` are now ``'auto'``. The smoothing kernel is a least-squares quartic polynomial kernel whose width is the largest odd number of oversampled grid points that is not larger than 0.7 times the FWHM of the ePSF (no smoothing below 5 grid points, i.e., for fewer than about 7 grid points per FWHM), and the fitting box is twice the FWHM of the ePSF in detector pixels (at least 5 pixels and at most the star cutout size). The FWHM is measured in each iteration along the narrowest axis of the ePSF. The previous fixed 5x5 ``'quartic'`` kernel (in oversampled grid points) and 5-pixel fitting box (in detector pixels) were designed for HST images with an oversampling factor of 4. A fixed kernel oversmooths heavily undersampled ePSFs, and a fixed 5-pixel fitting box applied to a well-sampled star uses only its flat core, which biases the fitted centers and can prevent convergence. The previous behavior is available with ``smoothing_kernel='quartic'`` and ``fit_shape=5``. An invalid string value for either parameter now raises a ``ValueError``. [#2421, #2465] - The following PSF flags have been renamed, along with their uppercase constants: ``outside_bounds`` (bit 2) to ``position_outside_image``, ``negative_flux`` (bit 4) to ``non_positive_flux``, ``near_bound`` (bit 32) to ``parameter_near_bound``, and ``non_finite_localbkg`` (bit 2048) to ``non_finite_local_bkg``. The old names are deprecated and will be removed in version 4.0. ``decode_psf_flags`` now returns the new names. [#2450] - ``photutils.segmentation`` - The ``SourceCatalog`` ``inertia_tensor`` property is deprecated and will be removed in version 4.0. It carries no information beyond the ``covariance`` matrix. [#2500] - The deblending warning information stored by ``deblend_sources`` in the returned ``SegmentationImage`` ``info`` dictionary has been restructured. The affected input labels are now stored as arrays directly under ``'non_positive_min_labels'`` and ``'too_many_markers_labels'`` keys. The nested ``info['warnings']`` dictionary, including its ``'message'`` entries, has been removed. The emitted warning is now a ``DeblendWarning`` (a subclass of astropy's ``AstropyUserWarning``) instead of an ``AstropyUserWarning``. [#2378] - ``SegmentationImage`` now raises a ``TypeError`` for masked array input. [#2381] - The conditions for the ``SourceCatalog`` ``centroid_win`` fallback to the isophotal centroid have been expanded. The windowed centroid is reset when it has diverged far from the isophotal centroid and lies outside the 1-sigma ellipse, the total weighted flux is nonpositive, the windowed second-order moments are negative, or the windowed covariance determinant is negative. Previously, only the ellipse condition was applied. [#2397] - The ``SourceCatalog`` ``progress_bar`` keyword is now deprecated and will be removed in version 4.0. All property calculations are now computed for all sources at once in compiled code, so no progress bar is displayed and the keyword has no effect. [#2406] - The ``deblend_sources`` and ``SourceFinder`` ``progress_bar`` and ``n_processes`` keywords are now deprecated and will be removed in version 4.0, and both keywords no longer have any effect. Deblending is now dominated by compiled code, so no progress bar is displayed, and the multiprocessing implementation has been removed because its process startup and data-pickling overheads made it slower than the serial implementation at any number of sources. [#2408] - The ``deblend_sources`` and ``SourceFinder`` deblending-mode fallback for sources with nonpositive minimum data values (used with ``mode='exponential'``, for which such sources are undefined) was changed from "linear" to "sinh". The sinh spacing keeps the threshold levels concentrated near the source minimum, recovering faint companions of bright sources that the linear spacing misses, so deblending results can change for the affected sources. The ``'non_positive_min_labels'`` info key and the ``deblend_non_positive_min`` flag are unchanged and continue to record the affected sources. [#2410] - ``detect_sources`` now emits an ``AstropyUserWarning`` if the ``threshold`` is negative or, for a threshold array, has any negative values. [#2429] - The ``SourceCatalog`` ``covariance`` is now regularized by raising each eigenvalue to at least ``1/12`` pixel**2 instead of adding ``1/12`` to both diagonal elements when the determinant is below ``(1/12)**2``. Thin sources that are unresolved along only their minor axis are now regularized, the variance along a resolved axis is no longer inflated, and the result is continuous. This affects the covariance-derived shape properties of unresolved sources (those with the ``singular_covariance`` flag) and the quantities that are derived from their shape, such as the Kron aperture and the windowed centroid and its errors. An exactly thin tilted source, whose covariance determinant is zero apart from rounding, is now regularized instead of having NaN shape properties. [#2437] - ``photutils.utils`` - The ``ImageDepth`` ``overlap_maxiters`` keyword is deprecated and will be removed in version 4.0. The keyword was unused and had no effect. [#2500] - The ``ShepardIDWInterpolator`` ``ncoords`` attribute has been renamed to ``n_coords``, consistent with the ``n_*`` naming used elsewhere in photutils. The old ``ncoords`` name is deprecated and will be removed in version 4.0. [#2346] - ``ImageDepth`` now creates a fresh random generator initialized with ``seed`` on every call instead of consuming a generator shared across calls. With a fixed seed, every call on the same instance now produces identical results. [#2375] - ``ShepardIDWInterpolator`` now returns a NumPy scalar for a single input position when ``n_neighbors=1``, consistent with the ``n_neighbors > 1`` case. Previously it returned a Python float. [#2375] - ``calc_total_error`` now returns an array whose dtype follows the NumPy type promotion of its floating-point ``data``, ``bkg_error``, and (array) ``effective_gain`` inputs, with a minimum of ``float32``. Previously the result was always ``float64``. For example, ``float32`` inputs now give a ``float32`` total error. Integer inputs still give a ``float64`` result, and the results for ``float64`` inputs are unchanged. A scalar ``effective_gain`` is also no longer expanded to a full-size array, which reduces the memory used. [#2438] 3.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 nonsquare 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 nonfinite values) was not applied to the raw data profile. [#2184] - ``photutils.psf`` - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now handle nonfinite (NaN or inf) local background values instead of raising an error. Three new flags have been added to identify sources with nonfinite values: flag 512 for nonfinite fitted positions, flag 1024 for nonfinite fitted flux, and flag 2048 for nonfinite 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 ``IsophoteList`` ``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 nonfinite 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] - 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] - ``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] 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 nonfinite 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 nonsquare 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 noncontiguous. [#2060] API Changes ^^^^^^^^^^^ - ``photutils.background`` - An explicit ``ValueError`` is now raised if the input ``data`` to ``Background2D`` contains all nonfinite values. [#2062] - ``photutils.psf`` - The ``GriddedPSFModel`` ``data`` and ``grid_xypos`` attributes are now read-only. [#2036] - The ``PSFPhotometry`` ``fit_params`` attribute is now deprecated. Use the ``results`` attribute 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`` (noncontiguous) 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 nonfinite. [#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 ``CircularGaussianSigmaPRF``. ``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``, ``reassign_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**31 - 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 nonoverlapping 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_psf_model_image`` function or the new ``make_model_image`` function instead. [#1762] - The ``make_random_gaussians_table`` function now includes an "id" column and always returns both ``'flux'`` and ``'amplitude'`` columns. [#1763] - The ``make_random_models_table`` function now includes an "id" column. [#1764] - The ``make_random_gaussians_table`` function is now deprecated. Use the ``make_random_models_table`` function instead. [#1764] - The ``make_test_psf_data`` function is now deprecated. Use the new ``make_psf_model_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 nonfinite 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``, ``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 nonconsecutive 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 nonuniform 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 (noncached) 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 nonpositive 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 nonfinite and the input array was integer type. [#1158] - Fixed an issue where ``RectangularAnnulus`` with a nondefault ``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] - 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 nonfinite 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] - Nonscalar 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 nonzero pixel and must not contain any nonfinite 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-24be529/CITATION.cff000066400000000000000000000110021526452531300174740ustar00rootroot00000000000000cff-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.1.0 date-released: '2026-10-13' astropy-photutils-24be529/CODE_OF_CONDUCT.rst000066400000000000000000000002571526452531300206230ustar00rootroot00000000000000Photutils is an `Astropy `_ affiliated package. We follow the `Astropy Community Code of Conduct `_. astropy-photutils-24be529/CONTRIBUTING.rst000066400000000000000000000117071526452531300202570ustar00rootroot00000000000000Contributing 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-24be529/LICENSE.rst000066400000000000000000000027471526452531300174360ustar00rootroot00000000000000Copyright (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-24be529/README.rst000066400000000000000000000064441526452531300173070ustar00rootroot00000000000000========= 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. 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-24be529/benchmarks/000077500000000000000000000000001526452531300177255ustar00rootroot00000000000000astropy-photutils-24be529/benchmarks/README.md000066400000000000000000000455101526452531300212110ustar00rootroot00000000000000# Photutils Benchmarks This directory contains standalone benchmark scripts for photutils. They are not part of the installed package or the test suite. Helper functions shared by the scripts live in `bench_helpers.py`. Every timing script accepts `--memory`, which also reports the peak and retained memory of each timed call. The call is made once more under `tracemalloc` before the timed repeats, so the times are unaffected, and the memory line is printed just before the timing row it belongs to. The peak is the largest traced memory above the level at the start of the call. The retained memory is what remains allocated after the call returns and unreachable objects are collected, such as values cached on an object that outlives the call. Only allocations made through the Python allocator are traced, which includes NumPy arrays but not memory that extension code obtains from `malloc` directly. ```bash python benchmarks/bench_segmentation.py --which catalog --memory ``` ## Aperture statistics (`bench_aperture_stats.py`) Benchmarks for `ApertureStats`: - computing the median and all properties for all of the pixel-based aperture types, with and without sigma clipping (the all-properties timings exclude the serial per-source cutout and bounding-box properties by default; use `--all-properties` to include them) - the cold cost of each individual property (including its lazy dependencies) for a circular aperture, sorted by decreasing sigma-clipped time ```bash python benchmarks/bench_aperture_stats.py python benchmarks/bench_aperture_stats.py --which threads --n-threads 1,4,12 ``` ## Aperture photometry (`bench_aperture_photometry.py`) Benchmarks for `AperturePhotometry`: computing the flux (with an error array) for all of the pixel-based aperture types and each overlap method (exact, center, and subpixel). ```bash python benchmarks/bench_aperture_photometry.py python benchmarks/bench_aperture_photometry.py --n-sources 100000 ``` ## SEP comparison (`bench_aperture_sep.py`) Benchmark and validation of the photutils aperture-photometry entry points (`aperture_photometry`, `AperturePhotometry`, and `ApertureStats`) against the SEP package, across masking scenarios (the `mask` keyword and segmentation-based masking), plus an `n_threads` sweep of `AperturePhotometry` against the single-threaded SEP baseline. Requires the optional `sep` package for the SEP comparisons. ```bash python benchmarks/bench_aperture_sep.py python benchmarks/bench_aperture_sep.py --which threads --n-threads 1,4,12 ``` ## SourceCatalog versus SEP (`bench_catalog_sep.py`) Benchmark and validation of `SourceCatalog` against the SEP package, using the same blended Gaussian-pair image, convolved image, and segmentation image as the `SourceCatalog` benchmarks in `bench_segmentation.py`. For each `aperture_mask_method` with a SEP analogue (`'none'` and `'mask'`), the script: - validates the isophotal properties (centroid and its error, covariance, shape parameters, ellipse coefficients, segment flux and area, peak, and bounding box) against `sep.extract` run on the existing segmentation map, and `kron_radius`, `kron_flux`, `flux_radius`, `centroid_win`, and `circular_photometry` against the corresponding SEP functions (the module docstring lists the conventions that make the two packages comparable) - benchmarks each of these measurements as a single step (on a catalog whose prerequisite properties are already computed) against the SEP function with its inputs precomputed, plus the full chain of measurements from a cold catalog, reporting the photutils/SEP runtime ratio The script also compares the two packages on a small scene of unresolved and thin sources (`--which degenerate`), which the Gaussian scene does not contain. The centroids, the floored minor axis, and every property of the nonsingular sources are checked strictly. The shape properties and centroid errors of singular sources differ by design, because `SourceCatalog` floors each covariance eigenvalue at `1/12` while SEP adds `1/12` to both variances, so they are only printed side by side. Requires the optional `sep` package. ```bash python benchmarks/bench_catalog_sep.py python benchmarks/bench_catalog_sep.py --which benchmark --n-sources 4000 python benchmarks/bench_catalog_sep.py --which degenerate ``` ## SourceCatalog cross-version validation (`validate_catalog_versions.py`) Validation of `SourceCatalog` results across photutils versions. The `dump` command computes every array-valued property (plus the `flux_radius`, `circular_photometry`, `kron_photometry`, and default `to_table` outputs) for the benchmark scene and an edge-case scene across several catalog configurations, using whichever photutils is importable, and writes them to a file; the `compare` command reports every entry that differs between two such files, grouped by property, and the properties that exist in only one version. The `dump` command uses only APIs available in photutils 3.0.0, so run it once per installation (e.g., with `PYTHONPATH` pointing at an unpacked wheel, from a directory without a photutils checkout) and then compare. ```bash python benchmarks/validate_catalog_versions.py dump old.pkl python benchmarks/validate_catalog_versions.py dump new.pkl python benchmarks/validate_catalog_versions.py compare old.pkl new.pkl ``` ## Centroids (`bench_centroids.py`) Benchmarks for the `photutils.centroids` subpackage: - the per-call cost of the single-source centroid functions (`centroid_com`, `centroid_quadratic`, `centroid_1dg`, and `centroid_2dg`), with and without an error array - `centroid_sources` at many positions for each centroid function. ```bash python benchmarks/bench_centroids.py python benchmarks/bench_centroids.py --which sources \ --n-sources 5000 --n-threads 1,4,8 ``` ## Geometry (`bench_geometry.py`) Benchmarks for the `photutils.geometry` subpackage: - the per-call cost of the overlap grid functions (`circular_overlap_grid`, `elliptical_overlap_grid`, `rectangular_overlap_grid`, and `polygon_overlap_grid`) for each overlap method (exact, center, and subpixel) - the scaling of the exact mode with grid size - the scaling of `polygon_overlap_grid` with the number of vertices, for convex and nonconvex (star-shaped) polygons ```bash python benchmarks/bench_geometry.py python benchmarks/bench_geometry.py --which polygon --n-vertices 8,64,512 ``` ## Isophote (`bench_isophote.py`) Benchmarks for the `photutils.isophote` subpackage, using a simulated galaxy image: - full isophote fitting with `Ellipse.fit_image` for each integration mode (a mode whose fit raises an error is reported as failed) - elliptical sample extraction (`EllipseSample.extract`) for each integration mode at several semimajor axis lengths - model-image reconstruction with `build_ellipse_model`, with and without the high-order harmonics - the scaling of the private `build_ellipse_model_c` Cython kernel with image size ```bash python benchmarks/bench_isophote.py python benchmarks/bench_isophote.py --which kernel --sizes 256,512,1024 ``` ## Morphology (`bench_morphology.py`) Benchmarks for the `photutils.morphology` subpackage: - the per-call cost of `data_properties`: catalog construction alone and with the morphological properties computed, with and without a mask, background, and WCS - the scaling of `gini` with array size, with and without a mask ```bash python benchmarks/bench_morphology.py python benchmarks/bench_morphology.py --which gini --sizes 256,1024,4096 ``` ## Profiles (`bench_profiles.py`) Benchmarks for the `photutils.profiles` subpackage: - the per-call cost of constructing each profile class (`RadialProfile`, `CurveOfGrowth`, `EnsquaredCurveOfGrowth`, and `EllipticalCurveOfGrowth`) and computing its profile, errors, and areas, for each overlap method (exact, center, and subpixel) - the scaling of the profile computation with the number of radial bins - the cost of the extra lazily-computed `RadialProfile` attributes (`data_profile`, `gaussian_fit`, and `moffat_fit`) ```bash python benchmarks/bench_profiles.py python benchmarks/bench_profiles.py --which radii-scaling --n-radii-list 100,400,1600 ``` ## PSF photometry (`bench_psf.py`) Benchmarks for the `photutils.psf` subpackage: - the per-call evaluation cost of each PSF model (the functional models, `ImagePSF`, and `GriddedPSFModel`) on small and large coordinate grids - `PSFPhotometry` versus the number of sources (with the source positions and fluxes given via `init_params`), with an error array and with a `SourceGrouper` - finder-based `PSFPhotometry` and `IterativePSFPhotometry` in the `new` and `all` modes - `PSFPhotometry` versus the PSF model type (each scene is rendered and fit with the same model) - the `ImagePSF` and `GriddedPSFModel` analytic Jacobians (`fit_deriv`) versus the fitter's finite-difference approximation, with speedups - `SourceGrouper` versus the number of sources and the minimum separation - ePSF building (`extract_stars` and `EPSFBuilder`) versus the number of stars - the Gaussian fitting helpers (`fit_2dgaussian` and `fit_fwhm`) on a single-source cutout and at many positions - `make_psf_model_image` versus the number of sources and the model cutout size ```bash python benchmarks/bench_psf.py python benchmarks/bench_psf.py --which photometry --n-sources-list 500,5000 ``` ## ePSF building (`bench_epsf_builder.py`) Benchmarks for `EPSFBuilder` on noisy Gaussian stars with random subpixel positions: - a full build (total time, number of iterations, time per iteration, and time per star per iteration) versus the number of stars - a full build versus the oversampling factor - the stages of a single build iteration (residual stacking, sigma clipping and median, smoothing and recentering, and star fitting) on a converged ePSF ```bash python benchmarks/bench_epsf_builder.py python benchmarks/bench_epsf_builder.py --which stages --n-stars 2000 python benchmarks/bench_epsf_builder.py --which stars --oversampling 4 --fwhm 1.5 ``` ## PSF matching (`bench_psf_matching.py`) Benchmarks for the `photutils.psf_matching` subpackage: - the per-call cost of the kernel-making functions (`make_kernel` with and without a window, and `make_wiener_kernel` for the scalar, Laplacian, and biharmonic penalties) - the scaling of the kernel computation with the PSF size - the per-call cost of the window classes - `resize_psf` for down- and upsampling with each spline order ```bash python benchmarks/bench_psf_matching.py python benchmarks/bench_psf_matching.py --which size-scaling --sizes 101,201,401 ``` ## Datasets (`bench_datasets.py`) Benchmarks for the `photutils.datasets` subpackage: - the scaling of `make_model_image` with the number of sources - `make_model_image` for each discretization method (`center`, `interp`, and `oversample`) - the scaling of `make_model_params` with the number of sources, including the minimum-separation (KDTree) filtering - the noise functions (`make_noise_image` for each distribution and `apply_poisson_noise`) - the WCS factories (`make_wcs` and `make_gwcs`) - the example-image functions (`make_4gaussians_image` and `make_100gaussians_image`) ```bash python benchmarks/bench_datasets.py python benchmarks/bench_datasets.py --which model-image --n-sources-list 500,2000 ``` ## Detection (`bench_detection.py`) Benchmarks for the `photutils.detection` subpackage: - the star finder classes (`DAOStarFinder`, `IRAFStarFinder`, and `StarFinder`) for full detection runs and, for the DAOFIND-style finders, catalog-only runs with the source positions given via `xycoords` - the `find_peaks` detection modes (`box_size`, `footprint`, `min_separation`, and centroiding) - the fast circular (`min_separation`) peak detection versus the equivalent circular-footprint maximum filter (with speedups) - concurrent `find_stars` calls on a shared `DAOStarFinder` instance across thread counts (with speedups relative to the first thread count) Examples: ```bash # Run everything with the default settings python benchmarks/bench_detection.py # Only the min-separation speedup benchmark python benchmarks/bench_detection.py --which min-separation --radii 10,50 ``` ## Segmentation (`bench_segmentation.py`) Benchmarks for the `photutils.segmentation` subpackage, using an image of blended Gaussian-source pairs so that deblending has work to do: - source detection (`detect_threshold` and `detect_sources` with 4- and 8-connectivity) - source deblending (`deblend_sources`) across the threshold modes (`linear`, `exponential`, and `sinh`) and `n_processes` values - the combined `SourceFinder` class with and without deblending - `SegmentationImage` operations (cached properties, relabeling, border-label removal, square and circular source masks, and polygons) - all `SourceCatalog` properties (from the catalog `properties` attribute, computed with convolved data, error, background, and WCS inputs) plus the method-based measurements (`flux_radius`, `circular_photometry`, `kron_photometry`, and `to_table`), each on a fresh catalog (times include dependent properties, e.g., `centroid_win` includes the Kron flux and `flux_radius` it depends on) - the `SourceCatalog` `n_threads` keyword across thread counts for each compiled per-source measurement (moments, segment statistics, centroids, perimeter, Kron and circular photometry, and flux radius), with the prerequisites of each measurement precomputed, plus the cold default `to_table` (with speedups relative to the first thread count) - concurrent `SourceCatalog` measurement jobs across thread counts (with speedups relative to the first thread count) Examples: ```bash # Run everything with the default settings python benchmarks/bench_segmentation.py # Only the deblending benchmark with a larger image python benchmarks/bench_segmentation.py --which deblend \ --n-sources 4000 --n-processes 1,4,8 # Only the SourceCatalog n_threads benchmark python benchmarks/bench_segmentation.py --which n-threads \ --n-sources 4000 --threads 1,4,8 ``` ## Deblending (`bench_deblend.py`) Benchmarks for `deblend_sources`: - many small sources (a grid of blended Gaussian-source pairs) versus the number of sources, for each threshold mode - a single large segment (a broad Gaussian envelope with superposed peaks) versus the segment size and the number of peaks, including the cost of the iterative contrast loop - the stages of the single-source deblending pipeline (multithresholding, marker building, watershed, and the contrast loop) - the `n_threads` keyword across thread counts - the basin and saddle contrast criteria - a cProfile mode (`--which profile`) - an optional comparison against the SEP package (`--which sep`, which requires `sep` and is not part of `all`): photutils detection and deblending (with both contrast criteria) against `sep.extract` with and without deblending on the same scenes, using the SEP deblending defaults for both packages, with the SEP / photutils time ratios ```bash python benchmarks/bench_deblend.py python benchmarks/bench_deblend.py --which sep --n-sources 1000,4000 --sizes 500,1000 ``` ## Background (`bench_background.py`) Benchmarks for the `photutils.background` subpackage: - `Background2D` construction across image sizes, box sizes, and `n_threads` values (with speedups relative to the first `n_threads` value) - full-size background and background RMS map generation - the scalar background and background RMS estimator classes, with and without sigma clipping - `LocalBackground` at many positions Examples: ```bash # Run everything with the default settings python benchmarks/bench_background.py # Only the Background2D n_threads scaling benchmark python benchmarks/bench_background.py --which background2d \ --sizes 2048,4096 --box-sizes 64 --n-threads 1,2,4,8 ``` ## WCS helpers (`bench_wcs_helpers.py`) Benchmarks for the local WCS helper functions in `photutils.utils._wcs_helpers` and the public pixel-area functions: - the per-call cost of the scalar helpers (local Jacobian, mean-scale and SVD conversions) on astropy TAN, TAN-SIP, and gwcs transforms - the vectorized helpers versus the number of positions, with the speedup over a per-source loop of the scalar mean-scale helper - `compute_pixel_area_map` versus image size and coarse-grid step - the aperture `to_pixel` and `to_sky` conversions that use the helpers, including the circular apertures with `as_ellipse=True` ```bash python benchmarks/bench_wcs_helpers.py python benchmarks/bench_wcs_helpers.py --which vectorized --n-positions 1000,100000 ``` ## SourceCatalog errors (`mc_catalog_err.py`) Monte Carlo verification of the `SourceCatalog` error properties (a validation script rather than a timing benchmark): - the pixel-to-sky error covariance transport shared by the isophotal, windowed, and quadratic sky centroid errors, checked against the scatter of pixel positions drawn from a known covariance and converted with the high-level WCS interface, on rotated, sheared, swapped-axis, SIP, near-pole, and gwcs transforms - the end-to-end errors of the isophotal, windowed, and quadratic centroids in pixel and sky coordinates and of the segment and Kron fluxes, checked against the scatter of the measured values over many noise realizations of a Gaussian source on a sheared, anisotropic WCS `SourceCatalog` zeroes negative data values within a segment before computing moments, so a segment that extends into wings below the noise gives a clipped isophotal centroid whose reported (linear) error is a few percent high. The defaults keep the segment edge well above the noise; `--radius 8` with the default amplitude shows the effect. The quadratic centroid error is a delta-method linearization evaluated at the noisy peak fit, so it is several percent high when the peak is only a few tens of sigma above the noise (`--amplitude 40`). The Kron flux scatter also includes the variation of the measured Kron aperture, which the reported error does not propagate. ```bash python benchmarks/mc_catalog_err.py python benchmarks/mc_catalog_err.py --which end-to-end --radius 8 --amplitude 40 ``` ## Utils (`bench_utils.py`) Benchmarks for the `photutils.utils` subpackage: - `ImageDepth` versus the number of apertures, in the overlapping and nonoverlapping modes - concurrent `ImageDepth` calls on a shared instance across thread counts (with speedups relative to the first thread count) - `ShepardIDWInterpolator` construction and evaluation versus the numbers of data points, query positions, and neighbors - cutout generation with `_make_cutouts` and `CutoutImage` - `calc_total_error` versus image size, with scalar and 2D gains - the NaN-ignoring statistics functions on float64 (bottleneck) and float32 (NumPy) arrays - `make_random_xycoords` with and without a minimum separation Examples: ```bash # Run everything with the default settings python benchmarks/bench_utils.py # Only the ImageDepth thread-scaling benchmark python benchmarks/bench_utils.py --which depth-threads --threads 1,4,8 ``` astropy-photutils-24be529/benchmarks/bench_aperture_photometry.py000077500000000000000000000132221526452531300255620ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for AperturePhotometry in the photutils.aperture subpackage. The benchmarks cover computing the flux (with an error array) for all of the pixel-based aperture types and each aperture overlap method (exact, center, and subpixel), and the thread scaling of the ``n_threads`` keyword. Run ``python benchmarks/bench_aperture_photometry.py --help`` to see the available options. """ import argparse from functools import partial from bench_helpers import (add_memory_argument, format_sweep_cells, make_aperture_inputs, make_apertures, parse_thread_counts, print_environment, set_memory_mode, time_best) from photutils.aperture import AperturePhotometry def _compute_flux(data, aperture, *, error=None, method='exact', n_threads=1): """ Construct an AperturePhotometry instance and compute the flux. """ return AperturePhotometry(data, aperture, error=error, method=method, n_threads=n_threads).flux def bench_photometry(size, n_sources, *, repeats=3, seed=0): """ Benchmark AperturePhotometry for all pixel-based aperture types. For each aperture type, the time to compute the flux (with an error array) is measured for each aperture overlap method. Parameters ---------- size : int The image size; the image is ``(size, size)``. n_sources : int The number of aperture positions. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== AperturePhotometry ' f'({n_sources} sources, {size}x{size} image) ==') print(f'{"aperture":>22}{"exact":>10}{"center":>10}{"subpixel":>10}') data, error, _, positions = make_aperture_inputs(size, n_sources, seed=seed) for name, aperture in make_apertures(positions): row = f'{name:>22}' for method in ('exact', 'center', 'subpixel'): t_flux = time_best( partial(_compute_flux, data, aperture, error=error, method=method), repeats=repeats) row += f'{f"{t_flux:.3f}s":>10}' print(row) def bench_thread_scaling(size, n_sources, n_threads_list, *, repeats=3, seed=0): """ Benchmark AperturePhotometry thread scaling for all pixel-based aperture types. For each aperture type, the time to compute the flux (with an error array and the 'exact' method) is measured for each thread count. Speedups are relative to the first thread count. Parameters ---------- size : int The image size; the image is ``(size, size)``. n_sources : int The number of aperture positions. n_threads_list : list of int The thread counts to sweep. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== AperturePhotometry n_threads sweep ' f'({n_sources} sources, {size}x{size} image, exact method) ==') header = f'{"aperture":>22}' header += ''.join(f'{f"n={n}":>16}' for n in n_threads_list) print(header) data, error, _, positions = make_aperture_inputs(size, n_sources, seed=seed) for name, aperture in make_apertures(positions): times = [time_best(partial(_compute_flux, data, aperture, error=error, n_threads=n_threads), repeats=repeats) for n_threads in n_threads_list] row = f'{name:>22}' row += ''.join(f'{cell:>16}' for cell in format_sweep_cells(times)) print(row) def main(): """ Run the AperturePhotometry benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for AperturePhotometry.') parser.add_argument('--size', type=int, default=2048, help='image size (default: %(default)s)') parser.add_argument('--n-sources', type=int, default=10000, help='number of sources (default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'types', 'threads'], help='which benchmark to run ' '(default: %(default)s)') parser.add_argument('--n-threads', type=parse_thread_counts, default='1,2,4,8', help='comma-separated thread counts for the ' 'threads benchmark (default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'types'): bench_photometry(args.size, args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'threads'): bench_thread_scaling(args.size, args.n_sources, args.n_threads, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_aperture_sep.py000077500000000000000000001012111526452531300241330ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmark and validation of photutils aperture photometry against SEP. The script builds a synthetic scene of 2D Gaussian sources (with a matching segmentation map) and, for a range of aperture shapes: 1. Validates that the photutils aperture-photometry entry points (``aperture_photometry``, ``AperturePhotometry``, and ``ApertureStats``) agree with each other for every ``mask_method`` value ('none', 'mask', 'source_only', 'background_only', and 'correct'). The legacy ``aperture_photometry`` function does not support segmentation masking, so it is validated and benchmarked only for the scenarios without it. 2. Validates the photutils results against SEP for the shapes that SEP supports (circle, circular annulus, ellipse, and elliptical annulus). SEP exact mode (``subpix=0``) is compared against the photutils ``method='exact'`` code path. 3. Benchmarks the runtime of each photutils entry point and SEP across four masking scenarios: no masking, the ``mask`` keyword only, segmentation masking only, and ``mask`` plus segmentation masking. 4. Sweeps ``AperturePhotometry`` over the ``--n-threads`` counts against the single-threaded SEP baseline (no-masking scenario). SEP segmentation masking maps to photutils as follows: ``mask_method='mask'`` corresponds to a positive SEP ``seg_id`` and ``mask_method='source_only'`` to a negative SEP ``seg_id``. The photutils ``'background_only'`` and ``'correct'`` mask methods have no SEP analogue and are only cross-checked internally. SEP ``sum_ellipann`` has no ``segmap`` support, so segmentation scenarios are skipped for the elliptical annulus when comparing to SEP. Two SEP semantic differences are handled by the validation: * SEP's ``mask`` array does not simply drop masked pixels: it rescales the sum by ``total_area / unmasked_area`` (it assumes masked pixels hold the aperture's average flux), while the photutils ``mask`` keyword drops masked pixels entirely. SEP value validation is therefore skipped for the ``mask``-keyword scenarios (their runtime is still benchmarked). SEP's segmentation masking does drop pixels and matches photutils. * ``source_only`` on an annulus selects only the central target-source pixels, which generally lie outside the annulus, giving an empty aperture. ``ApertureStats`` reports an empty aperture as NaN while the other entry points report 0.0; the validation treats these as equivalent. Run ``python benchmarks/bench_aperture_sep.py --help`` to see the available options. """ import argparse import sys import numpy as np from bench_helpers import (add_memory_argument, parse_thread_counts, print_environment, set_memory_mode, time_best) from numpy.testing import assert_allclose from photutils.aperture import (AperturePhotometry, ApertureStats, CircularAnnulus, CircularAperture, EllipticalAnnulus, EllipticalAperture, PolygonAperture, RectangularAnnulus, RectangularAperture, aperture_photometry) from photutils.segmentation import SegmentationImage try: import sep HAS_SEP = True SEP_IMPORT_ERROR = None except ImportError as exc: HAS_SEP = False SEP_IMPORT_ERROR = str(exc) def make_gaussian_scene(n_sources, shape, *, seed=0): """ Build a synthetic scene of 2D Gaussian sources on a noisy background. Parameters ---------- n_sources : int The number of Gaussian sources. shape : tuple of int The ``(ny, nx)`` shape of the image. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The float64 image of summed Gaussians plus background noise. segm : 2D `~numpy.ndarray` The segmentation map, where each pixel is owned by the source (positive integer label) that contributes the most flux there, or 0 for the background. positions : 2D `~numpy.ndarray` The ``(x, y)`` source center positions, shape ``(n_kept, 2)``. A source that owns no pixels in the segmentation map (it is entirely overtaken by a brighter neighbor) is dropped, so ``n_kept`` can be less than ``n_sources``. labels : 1D `~numpy.ndarray` The per-source integer labels (a subset of ``1 .. n_sources``). Every label is present in ``segm``. """ rng = np.random.default_rng(seed) ny, nx = shape noise = 1.0 data = rng.normal(0.0, noise, shape) segm = np.zeros(shape, dtype=np.intp) best = np.zeros(shape) # owning (max) contribution per pixel margin = 20 xc = rng.uniform(margin, nx - margin, n_sources) yc = rng.uniform(margin, ny - margin, n_sources) positions = np.column_stack([xc, yc]) amps = rng.uniform(50.0, 500.0, n_sources) sigmas = rng.uniform(1.5, 3.0, n_sources) labels = np.arange(1, n_sources + 1) own_thresh = 5.0 * noise # background stays unlabeled (label 0) for label, (x, y, amp, sig) in enumerate( zip(xc, yc, amps, sigmas, strict=True), start=1): half = int(np.ceil(4.0 * sig)) x0, x1 = max(int(x) - half, 0), min(int(x) + half + 1, nx) y0, y1 = max(int(y) - half, 0), min(int(y) + half + 1, ny) yy, xx = np.mgrid[y0:y1, x0:x1] g = amp * np.exp(-((xx - x)**2 + (yy - y)**2) / (2.0 * sig**2)) data[y0:y1, x0:x1] += g sub_best = best[y0:y1, x0:x1] sel = (g > sub_best) & (g > own_thresh) sub_best[sel] = g[sel] seg_sub = segm[y0:y1, x0:x1] seg_sub[sel] = label # The photutils segmentation masking requires every label to be # present in the segmentation map owned = np.isin(labels, segm) n_dropped = np.count_nonzero(~owned) if n_dropped: print(f'Dropped {n_dropped} of {n_sources} sources that own no ' 'pixels in the segmentation map') positions = positions[owned] labels = labels[owned] return np.ascontiguousarray(data), segm, positions, labels def build_shapes(): """ Build the registry of aperture shapes to benchmark. Each entry provides a photutils aperture factory and, where SEP supports the shape, a SEP wrapper. SEP exact mode (``subpix=0``) is used so that the SEP results are directly comparable to the photutils ``method='exact'`` results. """ r_circ = 6.0 # circle r_in, r_out = 6.0, 10.0 # circular annulus a_ell, b_ell, theta = 8.0, 4.0, 0.5 # ellipse # Elliptical annulus (SEP scales (a, b) by r_in/r_out) e_rin, e_rout = 0.6, 1.0 a_in, a_out = a_ell * e_rin, a_ell * e_rout b_out = b_ell * e_rout # Rectangle and rectangular annulus (no SEP analogue) w, h = 12.0, 8.0 w_in, w_out, h_out = 8.0, 14.0, 10.0 # Polygon: regular hexagon of "radius" 7 (no SEP analogue) ang = np.linspace(0.0, 2.0 * np.pi, 6, endpoint=False) hexagon = np.column_stack([7.0 * np.cos(ang), 7.0 * np.sin(ang)]) def sep_circle(data, x, y, **kwargs): """ Run SEP exact circular-aperture photometry. Parameters ---------- data : 2D `~numpy.ndarray` The image. x, y : 1D `~numpy.ndarray` The source center positions. **kwargs : dict Keyword arguments passed to the SEP function. Returns ------- result : tuple of `~numpy.ndarray` The SEP ``(flux, flux_err, flag)`` arrays. """ return sep.sum_circle(data, x, y, r_circ, subpix=0, **kwargs) def sep_circann(data, x, y, **kwargs): """ Run SEP exact circular-annulus photometry. Parameters ---------- data : 2D `~numpy.ndarray` The image. x, y : 1D `~numpy.ndarray` The source center positions. **kwargs : dict Keyword arguments passed to the SEP function. Returns ------- result : tuple of `~numpy.ndarray` The SEP ``(flux, flux_err, flag)`` arrays. """ return sep.sum_circann(data, x, y, r_in, r_out, subpix=0, **kwargs) def sep_ellipse(data, x, y, **kwargs): """ Run SEP exact elliptical-aperture photometry. Parameters ---------- data : 2D `~numpy.ndarray` The image. x, y : 1D `~numpy.ndarray` The source center positions. **kwargs : dict Keyword arguments passed to the SEP function. Returns ------- result : tuple of `~numpy.ndarray` The SEP ``(flux, flux_err, flag)`` arrays. """ return sep.sum_ellipse(data, x, y, a_ell, b_ell, theta, 1.0, subpix=0, **kwargs) def sep_ellipann(data, x, y, **kwargs): """ Run SEP exact elliptical-annulus photometry. Parameters ---------- data : 2D `~numpy.ndarray` The image. x, y : 1D `~numpy.ndarray` The source center positions. **kwargs : dict Keyword arguments passed to the SEP function. Returns ------- result : tuple of `~numpy.ndarray` The SEP ``(flux, flux_err, flag)`` arrays. """ return sep.sum_ellipann(data, x, y, a_ell, b_ell, theta, e_rin, e_rout, subpix=0, **kwargs) return [ {'name': 'circle', 'aperture': lambda pos: CircularAperture(pos, r=r_circ), 'sep': sep_circle, 'sep_segm': True}, {'name': 'circ_annulus', 'aperture': lambda pos: CircularAnnulus(pos, r_in=r_in, r_out=r_out), 'sep': sep_circann, 'sep_segm': True}, {'name': 'ellipse', 'aperture': lambda pos: EllipticalAperture(pos, a_ell, b_ell, theta=theta), 'sep': sep_ellipse, 'sep_segm': True}, {'name': 'ellipse_annulus', 'aperture': lambda pos: EllipticalAnnulus(pos, a_in, a_out, b_out, theta=theta), 'sep': sep_ellipann, 'sep_segm': False}, {'name': 'rectangle', 'aperture': lambda pos: RectangularAperture(pos, w, h, theta=theta), 'sep': None, 'sep_segm': False}, {'name': 'rect_annulus', 'aperture': lambda pos: RectangularAnnulus(pos, w_in, w_out, h_out, theta=theta), 'sep': None, 'sep_segm': False}, {'name': 'polygon', 'aperture': lambda pos: PolygonAperture(pos, hexagon), 'sep': None, 'sep_segm': False}, ] def build_scenarios(): """ Build the four masking scenarios, each expanded over the applicable ``mask_method`` values. """ return [ {'name': 'no masking', 'use_mask': False, 'use_segm': False, 'methods': ['none']}, {'name': 'mask keyword only', 'use_mask': True, 'use_segm': False, 'methods': ['none']}, {'name': 'segmentation only', 'use_mask': False, 'use_segm': True, 'methods': ['mask', 'source_only', 'background_only', 'correct']}, {'name': 'mask + segmentation', 'use_mask': True, 'use_segm': True, 'methods': ['mask', 'source_only', 'background_only', 'correct']}, ] def _phot_kwargs(scenario, method, segm, labels, maskarr): """ Build the photutils keyword arguments for a masking scenario. Parameters ---------- scenario : dict The masking scenario (an entry from ``build_scenarios``). method : str The segmentation ``mask_method``. segm : `~photutils.segmentation.SegmentationImage` The segmentation image. labels : 1D `~numpy.ndarray` The per-source segmentation labels. maskarr : 2D `~numpy.ndarray` (bool) The pixel mask. Returns ------- kwargs : dict The keyword arguments for the photutils entry points. """ kwargs = {} if scenario['use_mask']: kwargs['mask'] = maskarr if scenario['use_segm']: kwargs['segmentation_image'] = segm kwargs['labels'] = labels kwargs['mask_method'] = method return kwargs def run_legacy_photometry(data, aper, error, scenario, method, segm, labels, maskarr): """ Run the legacy ``aperture_photometry`` function. Parameters ---------- data : 2D `~numpy.ndarray` The image. aper : `~photutils.aperture.Aperture` The aperture containing all source positions. error : 2D `~numpy.ndarray` The total error array. scenario : dict The masking scenario (an entry from ``build_scenarios``). method : str The segmentation ``mask_method``. segm : `~photutils.segmentation.SegmentationImage` The segmentation image. labels : 1D `~numpy.ndarray` The per-source segmentation labels. maskarr : 2D `~numpy.ndarray` (bool) The pixel mask. Returns ------- flux, flux_err : 1D `~numpy.ndarray` The per-source aperture sums and their errors. """ kwargs = _phot_kwargs(scenario, method, segm, labels, maskarr) tbl = aperture_photometry(data, aper, error=error, method='exact', **kwargs) return (np.asarray(tbl['aperture_sum']), np.asarray(tbl['aperture_sum_err'])) def run_aperture_photometry(data, aper, error, scenario, method, segm, labels, maskarr, *, n_threads=1): """ Run the ``AperturePhotometry`` class. Parameters ---------- data : 2D `~numpy.ndarray` The image. aper : `~photutils.aperture.Aperture` The aperture containing all source positions. error : 2D `~numpy.ndarray` The total error array. scenario : dict The masking scenario (an entry from ``build_scenarios``). method : str The segmentation ``mask_method``. segm : `~photutils.segmentation.SegmentationImage` The segmentation image. labels : 1D `~numpy.ndarray` The per-source segmentation labels. maskarr : 2D `~numpy.ndarray` (bool) The pixel mask. n_threads : int, optional The number of threads to use. Returns ------- flux, flux_err : 1D `~numpy.ndarray` The per-source aperture fluxes and their errors. """ kwargs = _phot_kwargs(scenario, method, segm, labels, maskarr) phot = AperturePhotometry(data, aper, error=error, method='exact', n_threads=n_threads, **kwargs) return phot.flux, phot.flux_err def run_aperture_stats(data, aper, error, scenario, method, segm, labels, maskarr, *, n_threads=1): """ Run the ``ApertureStats`` class (sum and sum error only). Parameters ---------- data : 2D `~numpy.ndarray` The image. aper : `~photutils.aperture.Aperture` The aperture containing all source positions. error : 2D `~numpy.ndarray` The total error array. scenario : dict The masking scenario (an entry from ``build_scenarios``). method : str The segmentation ``mask_method``. segm : `~photutils.segmentation.SegmentationImage` The segmentation image. labels : 1D `~numpy.ndarray` The per-source segmentation labels. maskarr : 2D `~numpy.ndarray` (bool) The pixel mask. n_threads : int, optional The number of threads to use. Returns ------- flux, flux_err : 1D `~numpy.ndarray` The per-source aperture sums and their errors. """ kwargs = _phot_kwargs(scenario, method, segm, labels, maskarr) stats = ApertureStats(data, aper, error=error, sum_method='exact', n_threads=n_threads, **kwargs) return np.asarray(stats.sum), np.asarray(stats.sum_err) def run_sep(shape, data, positions, error, scenario, method, segm32): """ Run the SEP function for ``shape`` under the given scenario. Parameters ---------- shape : dict The aperture shape (an entry from ``build_shapes``). data : 2D `~numpy.ndarray` The image. positions : 2D `~numpy.ndarray` The ``(x, y)`` source center positions. error : 2D `~numpy.ndarray` The total error array. scenario : dict The masking scenario (an entry from ``build_scenarios``). method : str The segmentation ``mask_method``. segm32 : 2D `~numpy.ndarray` The segmentation map as int32 (required by SEP). Returns ------- flux, flux_err : 1D `~numpy.ndarray` The per-source aperture sums and their errors. """ x = np.ascontiguousarray(positions[:, 0]) y = np.ascontiguousarray(positions[:, 1]) kwargs = {'err': error} if scenario['use_mask']: # SEP masks a pixel when mask > maskthresh (default 0.0) kwargs['mask'] = scenario['_maskarr'] if scenario['use_segm']: kwargs['segmap'] = segm32 seg_id = scenario['_labels'].astype(np.int32) if method == 'source_only': seg_id = -seg_id kwargs['seg_id'] = seg_id flux, flux_err, _ = shape['sep'](data, x, y, **kwargs) return np.asarray(flux), np.asarray(flux_err) def _sanitize(arr): """ Map empty-aperture sentinels to 0 so that the ``ApertureStats`` NaN convention agrees with the 0.0 returned by the other entry points (and by SEP). Parameters ---------- arr : array_like The values to sanitize. Returns ------- result : `~numpy.ndarray` The float array with nonfinite values replaced by 0. """ return np.nan_to_num(np.asarray(arr, dtype=float), nan=0.0, posinf=0.0, neginf=0.0) def _sep_supported(shape, scenario, method, *, for_validation=False): """ Determine whether SEP supports (and is comparable for) a cell. Parameters ---------- shape : dict The aperture shape (an entry from ``build_shapes``). scenario : dict The masking scenario (an entry from ``build_scenarios``). method : str The segmentation ``mask_method``. for_validation : bool, optional Whether the cell is a value validation rather than a timing. Returns ------- supported : bool Whether the SEP comparison is supported. """ supported = (HAS_SEP and shape['sep'] is not None and method in ('none', 'mask', 'source_only') and (not scenario['use_segm'] or shape['sep_segm'])) if for_validation: # SEP area-corrects mask-keyword pixels instead of dropping # them, so the values are not comparable (see the module # docstring) supported = supported and not scenario['use_mask'] return supported def validate(data, positions, labels, segm, maskarr, error, shapes, scenarios, *, internal_rtol=1e-9, sep_rtol=1e-5, atol=1e-5): """ Validate photutils self-consistency and agreement with SEP. A small ``atol`` absorbs the negligible floating-point residue (~1e-8) that an empty aperture can leave in the photometry sum/error versus the NaN (mapped to 0) reported by ``ApertureStats``. Parameters ---------- data : 2D `~numpy.ndarray` The image. positions : 2D `~numpy.ndarray` The ``(x, y)`` source center positions. labels : 1D `~numpy.ndarray` The per-source segmentation labels. segm : 2D `~numpy.ndarray` The segmentation map. maskarr : 2D `~numpy.ndarray` (bool) The pixel mask. error : 2D `~numpy.ndarray` The total error array. shapes : list of dict The aperture shapes from ``build_shapes``. scenarios : list of dict The masking scenarios from ``build_scenarios``. internal_rtol : float, optional The relative tolerance for the photutils internal agreement. sep_rtol : float, optional The relative tolerance for the SEP agreement. atol : float, optional The absolute tolerance for both comparisons. Returns ------- n_fail : int The number of failed checks. """ segm32 = np.ascontiguousarray(segm, dtype=np.int32) segm_img = SegmentationImage(segm) n_fail = 0 print('\n== Validation ==') for scenario in scenarios: scenario['_maskarr'] = maskarr scenario['_labels'] = labels print(f'\nScenario: {scenario["name"]}') for shape in shapes: aper = shape['aperture'](positions) for method in scenario['methods']: ap_s, ap_e = run_aperture_photometry( data, aper, error, scenario, method, segm_img, labels, maskarr) st_s, st_e = run_aperture_stats( data, aper, error, scenario, method, segm_img, labels, maskarr) internal_ok = True try: assert_allclose(_sanitize(st_s), _sanitize(ap_s), rtol=internal_rtol, atol=atol) assert_allclose(_sanitize(st_e), _sanitize(ap_e), rtol=internal_rtol, atol=atol) # The legacy function does not support # segmentation masking if not scenario['use_segm']: lg_s, lg_e = run_legacy_photometry( data, aper, error, scenario, method, segm, labels, maskarr) assert_allclose(_sanitize(lg_s), _sanitize(ap_s), rtol=internal_rtol, atol=atol) assert_allclose(_sanitize(lg_e), _sanitize(ap_e), rtol=internal_rtol, atol=atol) except AssertionError: internal_ok = False n_fail += 1 sep_str = '' if _sep_supported(shape, scenario, method, for_validation=True): sep_s, _ = run_sep(shape, data, positions, error, scenario, method, segm32) sep_s = _sanitize(sep_s) ref = _sanitize(ap_s) reldiff = np.max(np.abs(sep_s - ref) / np.maximum(np.abs(ref), 1e-12)) sep_ok = True try: assert_allclose(sep_s, ref, rtol=sep_rtol, atol=atol) except AssertionError: sep_ok = False n_fail += 1 sep_str = (f' SEP {"ok " if sep_ok else "FAIL"}' f'(max reldiff {reldiff:.2e})') elif HAS_SEP and shape['sep'] is not None: if scenario['use_mask']: sep_str = ' SEP n/a (mask area-corrected)' else: sep_str = ' SEP n/a (no analogue)' status = 'PASS' if internal_ok else 'FAIL' print(f' {shape["name"]:16s} {method:16s} ' f'internal {status}{sep_str}') result = 'ALL PASS' if n_fail == 0 else f'{n_fail} FAILURE(S)' print(f'\nValidation result: {result}') return n_fail def benchmark(data, positions, labels, segm, maskarr, error, shapes, scenarios, *, repeats=3, n_threads=1): """ Benchmark each photutils entry point and SEP across scenarios. The ``n_threads`` keyword applies to the ``AperturePhotometry`` and ``ApertureStats`` classes only; the legacy ``aperture_photometry`` function and SEP are single threaded. Parameters ---------- data : 2D `~numpy.ndarray` The image. positions : 2D `~numpy.ndarray` The ``(x, y)`` source center positions. labels : 1D `~numpy.ndarray` The per-source segmentation labels. segm : 2D `~numpy.ndarray` The segmentation map. maskarr : 2D `~numpy.ndarray` (bool) The pixel mask. error : 2D `~numpy.ndarray` The total error array. shapes : list of dict The aperture shapes from ``build_shapes``. scenarios : list of dict The masking scenarios from ``build_scenarios``. repeats : int, optional The number of repeats for each timing (best time is kept). n_threads : int, optional The number of threads for the class-based entry points. """ segm32 = np.ascontiguousarray(segm, dtype=np.int32) segm_img = SegmentationImage(segm) n_src = positions.shape[0] print(f'\n== Benchmark (best of {repeats}, {n_src} sources, ' f'{data.shape[0]}x{data.shape[1]} image, ' f'n_threads={n_threads}) ==') header = (f'{"shape":16s} {"scenario":20s} {"method":16s} ' f'{"ApPhot":>9s} {"legacy":>9s} {"ApStats":>9s} ' f'{"SEP":>9s} {"ApPhot/SEP":>11s}') for scenario in scenarios: scenario['_maskarr'] = maskarr scenario['_labels'] = labels for shape in shapes: aper = shape['aperture'](positions) print('\n' + header) print('-' * len(header)) for scenario in scenarios: for method in scenario['methods']: t_ap = time_best( lambda a=aper, sc=scenario, m=method: run_aperture_photometry( data, a, error, sc, m, segm_img, labels, maskarr, n_threads=n_threads), repeats=repeats) if scenario['use_segm']: # The legacy function does not support # segmentation masking lg_ms = f'{"--":>9s}' else: t_lg = time_best( lambda a=aper, sc=scenario, m=method: run_legacy_photometry( data, a, error, sc, m, segm_img, labels, maskarr), repeats=repeats) lg_ms = f'{t_lg * 1e3:9.2f}' t_st = time_best( lambda a=aper, sc=scenario, m=method: run_aperture_stats( data, a, error, sc, m, segm_img, labels, maskarr, n_threads=n_threads), repeats=repeats) if _sep_supported(shape, scenario, method): t_sep = time_best( lambda s=shape, sc=scenario, m=method: run_sep(s, data, positions, error, sc, m, segm32), repeats=repeats) sep_ms = f'{t_sep * 1e3:9.2f}' ratio = f'{t_ap / t_sep:11.2f}' else: sep_ms = f'{"--":>9s}' ratio = f'{"--":>11s}' print(f'{shape["name"]:16s} {scenario["name"]:20s} ' f'{method:16s} {t_ap * 1e3:9.2f} ' f'{lg_ms} {t_st * 1e3:9.2f} ' f'{sep_ms} {ratio}') print('\n(times in ms; ApPhot/SEP = AperturePhotometry / SEP ' 'runtime ratio, lower is faster)') def bench_thread_sweep(data, positions, error, shapes, n_threads_list, *, repeats=3): """ Sweep ``AperturePhotometry`` over thread counts against SEP. Only the no-masking scenario is timed. SEP is single threaded, so its baseline is one column; the final column is the ``AperturePhotometry`` to SEP runtime ratio at the last thread count. Parameters ---------- data : 2D `~numpy.ndarray` The image. positions : 2D `~numpy.ndarray` The ``(x, y)`` source center positions. error : 2D `~numpy.ndarray` The total error array. shapes : list of dict The aperture shapes from ``build_shapes``. n_threads_list : list of int The thread counts to sweep. repeats : int, optional The number of repeats for each timing (best time is kept). """ scenario = build_scenarios()[0] # no masking method = 'none' n_src = positions.shape[0] n_last = n_threads_list[-1] print(f'\n== AperturePhotometry vs SEP n_threads sweep ' f'(no masking, best of {repeats}, {n_src} sources, ' f'{data.shape[0]}x{data.shape[1]} image) ==') header = f'{"shape":16s} {"SEP":>9s}' header += ''.join(f'{f"n={n}":>16s}' for n in n_threads_list) header += f'{f"ApPhot(n={n_last})/SEP":>20s}' print(header) print('-' * len(header)) for shape in shapes: aper = shape['aperture'](positions) times = [time_best( lambda a=aper, n=n_threads: run_aperture_photometry( data, a, error, scenario, method, None, None, None, n_threads=n), repeats=repeats) for n_threads in n_threads_list] if HAS_SEP and shape['sep'] is not None: t_sep = time_best( lambda s=shape: run_sep(s, data, positions, error, scenario, method, None), repeats=repeats) sep_ms = f'{t_sep * 1e3:9.2f}' ratio = f'{times[-1] / t_sep:20.2f}' else: sep_ms = f'{"--":>9s}' ratio = f'{"--":>20s}' row = f'{shape["name"]:16s} {sep_ms}' row += f'{times[0] * 1e3:16.2f}' row += ''.join( f'{f"{t * 1e3:.2f} ({times[0] / t:.1f}x)":>16s}' for t in times[1:]) row += ratio print(row) print('\n(times in ms; speedups relative to the first thread ' 'count; ApPhot/SEP lower is faster)') def main(): """ Run the SEP comparison benchmarks. """ parser = argparse.ArgumentParser( description='Benchmark photutils aperture photometry against ' 'SEP.') parser.add_argument('--size', type=int, default=1024, help='image size (default: %(default)s)') parser.add_argument('--n-sources', type=int, default=1000, help='number of sources (default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--n-threads', type=parse_thread_counts, default='1,8', help='comma-separated thread counts for the ' 'AperturePhotometry and ApertureStats ' 'classes; the benchmark grid uses the ' 'first count and the threads sweep uses ' 'all of them; the legacy function and SEP ' 'are single threaded ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'validate', 'benchmark', 'threads'], help='which suite to run (default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if HAS_SEP: print(f'sep {sep.__version__}') else: print(f'WARNING: the "sep" package is not importable in this ' f'interpreter ({sys.executable}), so all SEP comparisons ' f'and timings are skipped.\n import error: ' f'{SEP_IMPORT_ERROR}') data, segm, positions, labels = make_gaussian_scene( args.n_sources, (args.size, args.size), seed=args.seed) error = np.full(data.shape, 1.0) rng = np.random.default_rng(args.seed + 1) maskarr = rng.random(data.shape) < 0.005 # ~0.5% pixels masked shapes = build_shapes() scenarios = build_scenarios() n_fail = 0 if args.which in ('all', 'validate'): n_fail = validate(data, positions, labels, segm, maskarr, error, shapes, scenarios) if args.which in ('all', 'benchmark'): benchmark(data, positions, labels, segm, maskarr, error, shapes, scenarios, repeats=args.repeats, n_threads=args.n_threads[0]) if args.which in ('all', 'threads'): bench_thread_sweep(data, positions, error, shapes, args.n_threads, repeats=args.repeats) if n_fail: sys.exit(1) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_aperture_stats.py000077500000000000000000000316411526452531300245130ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for ApertureStats in the photutils.aperture subpackage. The benchmarks cover computing the ApertureStats properties for all of the pixel-based aperture types, with and without sigma clipping, the cold cost of each individual ApertureStats property (including its lazy dependencies) for a circular aperture, and the thread scaling of the ``n_threads`` keyword. The all-properties timings exclude the properties that construct per-source Python objects (the cutouts and bounding boxes) by default because they do not parallelize with ``n_threads`` and would otherwise dominate the timings. Use ``--all-properties`` to include them. Run ``python benchmarks/bench_aperture_stats.py --help`` to see the available options. """ import argparse from functools import partial from astropy.stats import SigmaClip from bench_helpers import (add_memory_argument, format_sweep_cells, make_aperture_inputs, make_apertures, parse_thread_counts, print_environment, set_memory_mode, time_best) from photutils.aperture import ApertureStats, CircularAperture # Properties that construct per-source Python objects (aperture-mask # cutouts and bounding boxes). They run serially regardless of # n_threads, so they are excluded from the all-properties timings by # default (see the --all-properties option). SERIAL_PROPERTIES = ('bbox', 'data_cutout', 'data_sum_cutout', 'error_sum_cutout') def _compute_all_properties(data, aperture, *, error=None, wcs=None, sigma_clip=None, n_threads=1, all_properties=False): """ Construct an ApertureStats instance and compute its properties. The serial cutout and bounding-box properties are skipped unless ``all_properties`` is `True`. """ apstats = ApertureStats(data, aperture, error=error, wcs=wcs, sigma_clip=sigma_clip, n_threads=n_threads) props = apstats.properties if not all_properties: props = [prop for prop in props if prop not in SERIAL_PROPERTIES] for prop in props: getattr(apstats, prop) def _compute_median(data, aperture, *, error=None, wcs=None, sigma_clip=None, n_threads=1): """ Construct an ApertureStats instance and compute the median. """ return ApertureStats(data, aperture, error=error, wcs=wcs, sigma_clip=sigma_clip, n_threads=n_threads).median def _compute_property(data, aperture, prop, *, error=None, wcs=None, sigma_clip=None, n_threads=1): """ Construct an ApertureStats instance and compute one property. """ apstats = ApertureStats(data, aperture, error=error, wcs=wcs, sigma_clip=sigma_clip, n_threads=n_threads) return getattr(apstats, prop) def bench_aperture_types(size, n_sources, n_threads_list, *, repeats=3, seed=0, all_properties=False): """ Benchmark ApertureStats for all pixel-based aperture types. For each aperture type, the time to compute the median and the time to compute the ApertureStats properties are measured, with and without sigma clipping. When more than one thread count is requested, additional tables sweep the all-properties timings over the thread counts, with speedups relative to the first thread count. Parameters ---------- size : int The image size; the image is ``(size, size)``. n_sources : int The number of aperture positions. n_threads_list : list of int The thread counts to sweep. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. all_properties : bool, optional Whether to include the serial cutout and bounding-box properties in the all-properties timings. """ if all_properties: note = 'all props includes the serial cutout/bbox properties' else: note = ('all props excludes the serial cutout/bbox properties; ' 'use --all-properties to include them') print(f'\n== ApertureStats aperture types ' f'({n_sources} sources, {size}x{size} image) ==') print(f'({note})') print(f'{"aperture":>22}{"median":>10}{"all props":>12}' f'{"all props+clip":>16}') (data, error, wcs, positions) = make_aperture_inputs(size, n_sources, seed=seed) sigma_clip = SigmaClip(sigma=3.0, maxiters=10) for name, aperture in make_apertures(positions): t_median = time_best( partial(_compute_median, data, aperture, error=error, wcs=wcs), repeats=repeats) t_all = time_best( partial(_compute_all_properties, data, aperture, error=error, wcs=wcs, all_properties=all_properties), repeats=repeats) t_all_clip = time_best( partial(_compute_all_properties, data, aperture, error=error, wcs=wcs, sigma_clip=sigma_clip, all_properties=all_properties), repeats=repeats) print(f'{name:>22}{f"{t_median:.3f}s":>10}' f'{f"{t_all:.3f}s":>12}{f"{t_all_clip:.3f}s":>16}') if len(n_threads_list) == 1: return for label, sigclip in (('all properties', None), ('all properties + sigma clip', sigma_clip)): print(f'\n== ApertureStats aperture types n_threads sweep: ' f'{label} ({n_sources} sources, {size}x{size} image) ==') print(f'({note})') header = f'{"aperture":>22}' header += ''.join(f'{f"n={n}":>18}' for n in n_threads_list) print(header) for name, aperture in make_apertures(positions): times = [time_best( partial(_compute_all_properties, data, aperture, error=error, wcs=wcs, sigma_clip=sigclip, n_threads=n_threads, all_properties=all_properties), repeats=repeats) for n_threads in n_threads_list] row = f'{name:>22}' row += ''.join(f'{cell:>18}' for cell in format_sweep_cells(times)) print(row) def bench_properties(size, n_sources, n_threads_list, *, repeats=3, seed=0): """ Benchmark each ApertureStats property for a circular aperture. Each property is timed on a fresh ApertureStats instance for each thread count, so a timing includes the cost of the property's uncomputed lazy dependencies (e.g., the pixel gather and sigma clipping). Two tables are printed (without and with sigma clipping), with the properties listed by decreasing single-threaded time. Speedups are relative to the first thread count. Parameters ---------- size : int The image size; the image is ``(size, size)``. n_sources : int The number of aperture positions. n_threads_list : list of int The thread counts to sweep. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ (data, error, wcs, positions) = make_aperture_inputs(size, n_sources, seed=seed) aperture = CircularAperture(positions, r=8.0) sigma_clip = SigmaClip(sigma=3.0, maxiters=10) prop_names = ApertureStats(data, aperture).properties for label, sigclip in (('no sigma clipping', None), ('sigma clipping', sigma_clip)): print(f'\n== ApertureStats properties, {label} (cold, incl. ' f'dependencies; CircularAperture r=8, {n_sources} ' f'sources) ==') header = f'{"property":>24}' header += ''.join(f'{f"n={n}":>18}' for n in n_threads_list) print(header) results = [] for prop in prop_names: times = [time_best( partial(_compute_property, data, aperture, prop, error=error, wcs=wcs, sigma_clip=sigclip, n_threads=n_threads), repeats=repeats) for n_threads in n_threads_list] results.append((prop, times)) results.sort(key=lambda result: result[1][0], reverse=True) for prop, times in results: row = f'{prop:>24}' row += ''.join(f'{cell:>18}' for cell in format_sweep_cells(times)) print(row) def bench_thread_scaling(size, n_sources, n_threads_list, *, repeats=3, seed=0): """ Benchmark ApertureStats thread scaling for all pixel-based aperture types. Two sweeps are measured for each aperture type: the aperture ``sum`` (with an error array), which runs entirely in the threaded ``sum_method`` gather, and the sigma-clipped ``median``, whose pixel gather and sigma clipping are threaded (the clipped order statistics reuse the clip kernel's sorted values). Properties dominated by the serial per-source reductions (e.g., the image moments) benefit less. Speedups are relative to the first thread count. Parameters ---------- size : int The image size; the image is ``(size, size)``. n_sources : int The number of aperture positions. n_threads_list : list of int The thread counts to sweep. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ (data, error, wcs, positions) = make_aperture_inputs(size, n_sources, seed=seed) sigma_clip = SigmaClip(sigma=3.0, maxiters=10) sweeps = (('sum', 'sum', None), ('sigma-clipped median', 'median', sigma_clip)) for label, prop, sigclip in sweeps: print(f'\n== ApertureStats n_threads sweep: {label} ' f'({n_sources} sources, {size}x{size} image) ==') header = f'{"aperture":>22}' header += ''.join(f'{f"n={n}":>16}' for n in n_threads_list) print(header) for name, aperture in make_apertures(positions): times = [time_best( partial(_compute_property, data, aperture, prop, error=error, wcs=wcs, sigma_clip=sigclip, n_threads=n_threads), repeats=repeats) for n_threads in n_threads_list] row = f'{name:>22}' row += ''.join(f'{cell:>16}' for cell in format_sweep_cells(times)) print(row) def main(): """ Run the aperture benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for ApertureStats.') parser.add_argument('--size', type=int, default=2048, help='image size (default: %(default)s)') parser.add_argument('--n-sources', type=int, default=5000, help='number of sources (default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'types', 'properties', 'threads'], help='which benchmark to run ' '(default: %(default)s)') parser.add_argument('--n-threads', type=parse_thread_counts, default='1,8', help='comma-separated thread counts for the ' 'thread sweeps (default: %(default)s)') parser.add_argument('--all-properties', action='store_true', help='include the serial cutout and bounding-box ' 'properties in the all-properties timings ' '(excluded by default because they do not ' 'parallelize with n_threads)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'types'): bench_aperture_types(args.size, args.n_sources, args.n_threads, repeats=args.repeats, seed=args.seed, all_properties=args.all_properties) if args.which in ('all', 'properties'): bench_properties(args.size, args.n_sources, args.n_threads, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'threads'): bench_thread_scaling(args.size, args.n_sources, args.n_threads, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_background.py000077500000000000000000000226741526452531300235730ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.background subpackage. The benchmarks cover Background2D construction across image sizes, box sizes, and n_threads values; full-size background and background RMS map generation; the scalar background and background RMS estimator classes; and LocalBackground. Run ``python benchmarks/bench_background.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from astropy.stats import SigmaClip from bench_helpers import (add_memory_argument, format_sweep_cells, make_image, print_environment, set_memory_mode, time_best) from photutils.background import (Background2D, BiweightLocationBackground, BiweightScaleBackgroundRMS, LocalBackground, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) ESTIMATOR_CLASSES = [MeanBackground, MedianBackground, ModeEstimatorBackground, MMMBackground, SExtractorBackground, BiweightLocationBackground, StdBackgroundRMS, MADStdBackgroundRMS, BiweightScaleBackgroundRMS] def bench_background2d(sizes, box_sizes, n_threads_list, *, repeats=3, seed=0): """ Benchmark Background2D construction. The construction time is measured for each combination of image size, box size, and number of threads. Speedups are reported relative to the first value in ``n_threads_list``. Parameters ---------- sizes : list of int The image sizes; each image is ``(size, size)``. box_sizes : list of int The box sizes; each box is ``(box_size, box_size)``. n_threads_list : list of int The ``n_threads`` values to benchmark. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print('\n== Background2D construction ==') header = f'{"image":>12} {"box":>6}' for n_threads in n_threads_list: header += f'{f"n_threads={n_threads}":>19}' print(header) for size in sizes: data = make_image((size, size), seed=seed) for box_size in box_sizes: row = f'{f"{size}x{size}":>12} {box_size:>6}' t_ref = None for n_threads in n_threads_list: func = partial(Background2D, data, box_size, n_threads=n_threads) t_best = time_best(func, repeats=repeats) if t_ref is None: t_ref = t_best cell = f'{t_best:.3f}s' else: cell = f'{t_best:.3f}s ({t_ref / t_best:.2f}x)' row += f'{cell:>19}' print(row) def bench_maps(sizes, n_threads_list, *, box_size=64, repeats=3, seed=0): """ Benchmark full-size background map generation. The ``background`` and ``background_rms`` properties are recalculated on each access, so their cost is paid every time they are used. The ``background`` access time is measured for each ``n_threads`` value, with speedups reported relative to the first value in ``n_threads_list``. Parameters ---------- sizes : list of int The image sizes; each image is ``(size, size)``. n_threads_list : list of int The ``n_threads`` values to benchmark. box_size : int, optional The box size used for the Background2D instance. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== Full-size background map generation (box={box_size}) ==') header = f'{"image":>12}' for n_threads in n_threads_list: header += f'{f"n_threads={n_threads}":>19}' print(header) for size in sizes: data = make_image((size, size), seed=seed) row = f'{f"{size}x{size}":>12}' t_ref = None for n_threads in n_threads_list: bkg = Background2D(data, box_size, n_threads=n_threads) t_bkg = time_best(partial(getattr, bkg, 'background'), repeats=repeats) if t_ref is None: t_ref = t_bkg cell = f'{t_bkg:.3f}s' else: cell = f'{t_bkg:.3f}s ({t_ref / t_bkg:.2f}x)' row += f'{cell:>19}' print(row) def bench_estimators(*, size=2048, repeats=3, seed=0): """ Benchmark the scalar background and background RMS estimators. Each estimator is timed on the full image, with and without sigma clipping. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== Scalar estimators ({size}x{size} image) ==') print(f'{"class":>28}{"no clipping":>14}{"sigma clipping":>17}') data = make_image((size, size), seed=seed) for cls in ESTIMATOR_CLASSES: est_noclip = cls(sigma_clip=None) est_clip = cls(sigma_clip=SigmaClip(sigma=3.0, maxiters=10)) t_noclip = time_best(partial(est_noclip, data), repeats=repeats) t_clip = time_best(partial(est_clip, data), repeats=repeats) print(f'{cls.__name__:>28}{f"{t_noclip:.3f}s":>14}' f'{f"{t_clip:.3f}s":>17}') def bench_local_background(n_threads_list, *, size=2048, n_positions=1000, repeats=3, seed=0): """ Benchmark LocalBackground at many positions for each thread count. Speedups are relative to the first thread count. Parameters ---------- n_threads_list : list of int The thread counts to sweep. size : int, optional The image size; the image is ``(size, size)``. n_positions : int, optional The number of aperture positions. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== LocalBackground ({n_positions} positions, ' f'{size}x{size} image) ==') header = ''.join(f'{f"n={n}":>18}' for n in n_threads_list) print(header) data = make_image((size, size), seed=seed) rng = np.random.default_rng(seed) x = rng.uniform(50, size - 50, n_positions) y = rng.uniform(50, size - 50, n_positions) times = [] for n_threads in n_threads_list: local_bkg = LocalBackground(5, 10, n_threads=n_threads) times.append(time_best(partial(local_bkg, data, x, y), repeats=repeats)) row = ''.join(f'{cell:>18}' for cell in format_sweep_cells(times)) print(row) def main(): """ Run the photutils.background benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.background subpackage.') parser.add_argument('--sizes', default='1024,2048,4096', help='comma-separated image sizes ' '(default: %(default)s)') # The large box keeps the per-box cost dominant for at least one # setting so that regressions there are visible. parser.add_argument('--box-sizes', default='32,64,128,1024', help='comma-separated box sizes ' '(default: %(default)s)') parser.add_argument('--n-threads', default='1,2,4,8', help='comma-separated n_threads values ' '(default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'background2d', 'maps', 'estimators', 'local'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) sizes = [int(size) for size in args.sizes.split(',')] box_sizes = [int(box) for box in args.box_sizes.split(',')] n_threads_list = [int(nthr) for nthr in args.n_threads.split(',')] print_environment() if args.which in ('all', 'background2d'): bench_background2d(sizes, box_sizes, n_threads_list, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'maps'): bench_maps(sizes, n_threads_list, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'estimators'): bench_estimators(repeats=args.repeats, seed=args.seed) if args.which in ('all', 'local'): bench_local_background(n_threads_list, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_catalog_sep.py000077500000000000000000001204351526452531300237270ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmark and validation of SourceCatalog against SEP. The script uses the same image of blended Gaussian-source pairs, the same convolved image, and the same segmentation image as the ``SourceCatalog`` benchmarks in ``bench_segmentation.py`` (see its ``make_inputs`` function) and, for each ``aperture_mask_method`` that has a SEP analogue ('none' and 'mask'): 1. Validates the ``SourceCatalog`` properties against the equivalent SEP measurements on the same inputs: * the isophotal properties (centroid and its error, covariance, semimajor and semiminor axes, orientation, ellipse coefficients, segment flux and area, peak value and position, and bounding box) against ``sep.extract`` run on the existing segmentation map (``segmentation_map=``) with the same convolution kernel (``filter_type='conv'``) * ``kron_radius`` against ``sep.kron_radius`` * ``kron_flux`` against ``sep.sum_ellipse`` in the Kron aperture * the ``kron_minimum_radius``, ``kron_partial_overlap``, and ``kron_neighbor_pixels`` flags against the ``sep.kron_radius`` and ``sep.sum_ellipse`` flags * ``flux_radius`` against ``sep.flux_radius`` * ``centroid_win`` against ``sep.winpos`` * ``circular_photometry`` against ``sep.sum_circle`` 2. Benchmarks each of these measurements, timing the ``SourceCatalog`` property on a fresh catalog whose prerequisite properties are already computed (so each row is the cost of that step alone) and the SEP function with its inputs precomputed, and then the full chain of measurements from a cold catalog against the same chain of SEP calls. 3. Compares the two packages on a small scene of unresolved and thin sources (``--which degenerate``), which the Gaussian scene does not contain. The quantities that both packages define the same way are checked strictly. The shape properties of singular sources differ by design and are only printed (see below). SEP's ``segmap`` and positive ``seg_id`` inputs mask the pixels of neighboring sources, which corresponds to the photutils ``aperture_mask_method='mask'``; without them, neighboring sources are included (``aperture_mask_method='none'``). The photutils ``'correct'`` method has no SEP analogue. ``sep.winpos`` has no segmentation-map support, so ``centroid_win`` is only compared and benchmarked against SEP in the 'none' scenario. The following conventions make the two packages comparable: * SEP computes the isophotal properties from the internally filtered image and the segment flux from the unfiltered image, exactly as ``SourceCatalog`` uses ``convolved_data`` and ``data``. The plain SEP convolution (``filter_type='conv'``) matches the zero-padded ``astropy.convolution.convolve`` used to make ``convolved_data``. SEP stores pixel values as float32, so the isophotal properties agree to roughly 1e-7 relative precision. * SEP objects are returned in segment-label order, matching the ``SourceCatalog`` label order. * ``SourceCatalog`` applies its ``kron_params`` to the measured Kron radius (the minimum unscaled radius ``kron_params[1]`` and a NaN above the 6.0 measurement scale), while ``sep.kron_radius`` returns the raw measurement. The same limits are applied to the SEP value before it is compared and before it defines the SEP Kron aperture. * SEP has no minimum Kron radius flag. The SEP equivalent of ``kron_minimum_radius`` is a raw ``sep.kron_radius`` value below ``kron_params[1]`` or the SEP ``APER_NONPOSITIVE`` flag, for which ``SourceCatalog`` also uses the minimum radius. * SEP sets ``APER_TRUNC`` from an aperture bounding box whose lower limits are truncated toward zero, so it misses an aperture that extends less than one pixel past the left or bottom edge of the image. ``kron_partial_overlap`` is therefore only required to be set wherever ``APER_TRUNC`` is set. * ``sep.flux_radius`` measures the flux in 256 annuli out to the maximum radius and linearly interpolates the cumulative profile, whereas ``SourceCatalog.flux_radius`` solves for the exact-overlap circular aperture enclosing the flux fraction. The two agree only to roughly 1% (``FLUX_RADIUS_RTOL``). For the ``centroid_win`` comparison, SEP is therefore given the window sigma derived from the photutils half-light radius so that the windowed algorithms are compared on the same window; its benchmark uses SEP's own chain. * SEP weights each pixel's variance by its aperture overlap fraction, whereas photutils weights it by the square of the overlap fraction, so the aperture flux errors are not compared. * The catalog has no ``background`` or ``wcs`` inputs, which have no SEP analogue. The two packages regularize a singular covariance matrix differently. SEP adds ``1/12`` to both variances when the determinant is below ``0.00694``. ``SourceCatalog`` raises each eigenvalue to at least ``1/12`` and keeps the eigenvectors. Both give a minor axis of ``1/sqrt(12)`` for a source that SEP flags, but SEP also inflates the resolved major axis, and its determinant test misses a source whose minor-axis variance is below ``1/12`` while its determinant is not. The shape properties and centroid errors of singular sources therefore differ by design. The degenerate scene has no single-pixel segment because ``sep.extract`` returns invalid values for one when it is given an existing segmentation map (SEP 1.4.1). Requires the optional ``sep`` package. Run ``python benchmarks/bench_catalog_sep.py --help`` to see the available options. """ import argparse import sys import time import astropy.units as u import numpy as np from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from bench_segmentation import THRESHOLD, make_inputs from numpy.testing import assert_allclose, assert_array_equal from photutils.segmentation import (SEGMENTATION_FLAGS, SegmentationImage, SourceCatalog, make_2dgaussian_kernel) try: import sep HAS_SEP = True SEP_IMPORT_ERROR = None except ImportError as exc: HAS_SEP = False SEP_IMPORT_ERROR = str(exc) KRON_PARAMS = (2.5, 1.4, 0.0) # the SourceCatalog default MAX_KRON_RADIUS = 6.0 # the SourceCatalog Kron measurement scale FRACTION = 0.5 # the flux_radius fraction (half-light radius) RADIUS = 5.0 # the circular photometry radius MIN_HALF_LIGHT_RADIUS = 0.5 # the centroid_win minimum (SourceExtractor) SEP_RTOL = 1e-5 SEP_ATOL = 1e-5 FLUX_RADIUS_RTOL = 1e-2 CENTROID_WIN_ATOL = 1e-5 # The degenerate scene has unit errors, so this SEP threshold keeps # every segment pixel DEGENERATE_THRESHOLD = 0.1 # The floored minor axis of a singular source in both packages MIN_SEMIMINOR_AXIS = np.sqrt(1.0 / 12.0) # The SourceCatalog properties that sep.extract also measures, in # the (name, SEP field) pairs used by the validation ISOPHOTAL_PROPERTIES = [ ('x_centroid', 'x'), ('y_centroid', 'y'), ('x_centroid_err', 'errx2'), ('y_centroid_err', 'erry2'), ('covariance_xx', 'x2'), ('covariance_yy', 'y2'), ('covariance_xy', 'xy'), ('semimajor_axis', 'a'), ('semiminor_axis', 'b'), ('orientation', 'theta'), ('ellipse_cxx', 'cxx'), ('ellipse_cyy', 'cyy'), ('ellipse_cxy', 'cxy'), ('segment_flux', 'flux'), ('moments', 'cflux'), ('max_value', 'peak'), ('max_value_xindex', 'xpeak'), ('max_value_yindex', 'ypeak'), ('bbox_xmin', 'xmin'), ('bbox_xmax', 'xmax'), ('bbox_ymin', 'ymin'), ('bbox_ymax', 'ymax'), ('segment_area', 'npix'), ('area', 'npix'), ] INTEGER_PROPERTIES = ('max_value_xindex', 'max_value_yindex', 'bbox_xmin', 'bbox_xmax', 'bbox_ymin', 'bbox_ymax', 'segment_area', 'area') def make_scene(n_sources, *, seed=0): """ Build the shared benchmark scene. Parameters ---------- n_sources : int The total number of Gaussian sources in the image. seed : int, optional The random number generator seed. Returns ------- scene : dict The ``data``, ``convolved_data``, ``kernel``, ``error``, ``segm`` (a `~photutils.segmentation.SegmentationImage`), ``segm32`` (the int32 segmentation array for SEP), and ``labels`` (int32, for SEP ``seg_id``) values. """ data, convolved_data, segm = make_inputs(n_sources, seed=seed) kernel = make_2dgaussian_kernel(3.0, size=5) # as in make_inputs return {'data': data, 'convolved_data': convolved_data, 'kernel': np.ascontiguousarray(kernel.array), 'error': np.ones_like(data), 'segm': segm, 'segm32': np.ascontiguousarray(segm.data, dtype=np.int32), 'labels': np.ascontiguousarray(segm.labels, dtype=np.int32)} def build_scenarios(): """ Build the ``aperture_mask_method`` scenarios that have a SEP analogue. Returns ------- result : list of dict The scenarios, each with the photutils ``method`` and a ``use_segm`` flag for the SEP ``segmap``/``seg_id`` inputs. """ return [ {'name': "aperture_mask_method='none' (SEP: no segmap)", 'method': 'none', 'use_segm': False}, {'name': "aperture_mask_method='mask' (SEP: segmap + seg_id)", 'method': 'mask', 'use_segm': True}, ] def make_catalog(scene, method): """ Make a fresh `~photutils.segmentation.SourceCatalog` for a scenario. Parameters ---------- scene : dict The scene from ``make_scene``. method : str The ``aperture_mask_method``. Returns ------- result : `~photutils.segmentation.SourceCatalog` The catalog. """ return SourceCatalog(scene['data'], scene['segm'], convolved_data=scene['convolved_data'], error=scene['error'], aperture_mask_method=method, kron_params=KRON_PARAMS) def compute_isophotal(catalog): """ Compute the isophotal properties of a catalog. Parameters ---------- catalog : `~photutils.segmentation.SourceCatalog` The catalog. """ for name, _ in ISOPHOTAL_PROPERTIES: getattr(catalog, name) def property_values(catalog, name): """ Return a catalog property as a plain float array in the SEP convention. Parameters ---------- catalog : `~photutils.segmentation.SourceCatalog` The catalog. name : str The property name. Returns ------- result : `~numpy.ndarray` The values (the orientation in radians, the zeroth-order moment for ``'moments'``). """ values = getattr(catalog, name) if name == 'orientation': return values.to_value(u.rad) if name == 'moments': return np.asarray(values[:, 0, 0], dtype=float) return np.asarray(getattr(values, 'value', values), dtype=float) def sep_values(objects, field): """ Return a ``sep.extract`` field in the photutils convention. Parameters ---------- objects : `~numpy.ndarray` The ``sep.extract`` structured array. field : str The field name. Returns ------- result : `~numpy.ndarray` The values (the centroid errors as standard deviations). """ values = np.asarray(objects[field], dtype=float) if field in ('errx2', 'erry2'): return np.sqrt(values) return values def sep_seg_kwargs(scene, scenario): """ Return the SEP ``segmap``/``seg_id`` keyword arguments for a scenario. Parameters ---------- scene : dict The scene from ``make_scene``. scenario : dict The scenario (an entry from ``build_scenarios``). Returns ------- result : dict The keyword arguments (empty for the 'none' scenario). """ if not scenario['use_segm']: return {} return {'segmap': scene['segm32'], 'seg_id': scene['labels']} def run_sep_extract(scene): """ Run ``sep.extract`` on the existing segmentation map. The detection threshold only sets the bookkeeping fields of the SEP objects (the segmentation map defines the members of every source); it matches the ``detect_sources`` threshold used to make the scene. Parameters ---------- scene : dict The scene from ``make_scene``. Returns ------- objects : `~numpy.ndarray` The ``sep.extract`` structured array, in segment-label order. """ objects, _ = sep.extract(scene['data'], THRESHOLD, err=scene['error'], filter_kernel=scene['kernel'], filter_type='conv', segmentation_map=scene['segm32']) return objects def sep_shape_inputs(objects): """ Return the per-source SEP aperture inputs from the extracted objects. Parameters ---------- objects : `~numpy.ndarray` The ``sep.extract`` structured array. Returns ------- x, y, a, b, theta : `~numpy.ndarray` The contiguous float64 centroid and ellipse parameter arrays. """ return tuple(np.ascontiguousarray(objects[field], dtype=np.float64) for field in ('x', 'y', 'a', 'b', 'theta')) def run_sep_kron_radius(scene, shape, seg_kwargs): """ Run ``sep.kron_radius`` and apply the ``SourceCatalog`` ``kron_params`` limits. Parameters ---------- scene : dict The scene from ``make_scene``. shape : tuple of `~numpy.ndarray` The ``(x, y, a, b, theta)`` arrays from ``sep_shape_inputs``. seg_kwargs : dict The SEP ``segmap``/``seg_id`` keyword arguments. Returns ------- kron_radius : `~numpy.ndarray` The unscaled Kron radius with the minimum radius applied and NaN above the measurement scale. raw_kron_radius : `~numpy.ndarray` The raw SEP measurement. """ x, y, a, b, theta = shape raw, _ = sep.kron_radius(scene['data'], x, y, a, b, theta, MAX_KRON_RADIUS, **seg_kwargs) raw = np.asarray(raw, dtype=float) kron_radius = np.where(raw > MAX_KRON_RADIUS, np.nan, np.maximum(raw, KRON_PARAMS[1])) return kron_radius, raw def run_sep_kron_flux(scene, shape, kron_radius, seg_kwargs): """ Run ``sep.sum_ellipse`` in the Kron aperture (exact overlap). Parameters ---------- scene : dict The scene from ``make_scene``. shape : tuple of `~numpy.ndarray` The ``(x, y, a, b, theta)`` arrays from ``sep_shape_inputs``. kron_radius : `~numpy.ndarray` The unscaled Kron radius (NaN where undefined). seg_kwargs : dict The SEP ``segmap``/``seg_id`` keyword arguments. Returns ------- flux : `~numpy.ndarray` The Kron flux (NaN where the Kron radius is undefined). """ x, y, a, b, theta = shape valid = np.isfinite(kron_radius) scale = np.where(valid, KRON_PARAMS[0] * kron_radius, 1.0) flux, _, _ = sep.sum_ellipse(scene['data'], x, y, a, b, theta, scale, err=scene['error'], subpix=0, **seg_kwargs) return np.where(valid, flux, np.nan) def run_sep_kron_flags(scene, shape, seg_kwargs): """ Return the SEP equivalents of the ``SourceCatalog`` Kron flags. Parameters ---------- scene : dict The scene from ``make_scene``. shape : tuple of `~numpy.ndarray` The ``(x, y, a, b, theta)`` arrays from ``sep_shape_inputs``. seg_kwargs : dict The SEP ``segmap``/``seg_id`` keyword arguments. Returns ------- result : dict of `~numpy.ndarray` The boolean SEP flags keyed by the ``SourceCatalog`` flag name. The ``kron_neighbor_pixels`` flag is included only when SEP is given a segmentation map. """ x, y, a, b, theta = shape raw, radius_flag = sep.kron_radius(scene['data'], x, y, a, b, theta, MAX_KRON_RADIUS, **seg_kwargs) raw = np.asarray(raw, dtype=float) kron_radius = np.where(raw > MAX_KRON_RADIUS, np.nan, np.maximum(raw, KRON_PARAMS[1])) valid = np.isfinite(kron_radius) scale = np.where(valid, KRON_PARAMS[0] * kron_radius, 1.0) _, _, flux_flag = sep.sum_ellipse(scene['data'], x, y, a, b, theta, scale, err=scene['error'], subpix=0, **seg_kwargs) flags = { 'kron_minimum_radius': ((raw < KRON_PARAMS[1]) | ((radius_flag & sep.APER_NONPOSITIVE) > 0)), 'kron_partial_overlap': valid & ((flux_flag & sep.APER_TRUNC) > 0), } if seg_kwargs: # The scene has no input mask, so the only masked pixels are # those of neighboring sources flags['kron_neighbor_pixels'] = ( valid & ((flux_flag & sep.APER_HASMASKED) > 0)) return flags def run_sep_flux_radius(scene, shape, kron_radius, kron_flux, seg_kwargs): """ Run ``sep.flux_radius`` normalized by the Kron flux. The maximum radius is the circular Kron radius ``kron_params[0] * kron_radius * a``, as in ``SourceCatalog.flux_radius``. Parameters ---------- scene : dict The scene from ``make_scene``. shape : tuple of `~numpy.ndarray` The ``(x, y, a, b, theta)`` arrays from ``sep_shape_inputs``. kron_radius : `~numpy.ndarray` The unscaled Kron radius (NaN where undefined). kron_flux : `~numpy.ndarray` The Kron flux. seg_kwargs : dict The SEP ``segmap``/``seg_id`` keyword arguments. Returns ------- radius : `~numpy.ndarray` The radius enclosing ``FRACTION`` of the Kron flux (NaN where the Kron radius or flux is undefined or the flux is zero). """ x, y, a, _, _ = shape rmax = KRON_PARAMS[0] * kron_radius * a valid = (np.isfinite(rmax) & np.isfinite(kron_flux) & (kron_flux != 0)) rmax = np.where(valid, rmax, 1.0) normflux = np.where(valid, kron_flux, 1.0) radius, _ = sep.flux_radius(scene['data'], x, y, rmax, FRACTION, normflux=normflux, subpix=5, **seg_kwargs) return np.where(valid, radius, np.nan) def window_sigma(half_light_radius): """ Return the windowed-centroid Gaussian sigma for a half-light radius, as in ``SourceCatalog.centroid_win``. Parameters ---------- half_light_radius : `~numpy.ndarray` The half-light radius. Returns ------- result : `~numpy.ndarray` The window sigma (nonfinite radii use the minimum radius). """ radius = np.where(np.isfinite(half_light_radius), half_light_radius, MIN_HALF_LIGHT_RADIUS) radius = np.maximum(radius, MIN_HALF_LIGHT_RADIUS) return 2.0 * gaussian_fwhm_to_sigma * radius def run_sep_winpos(scene, shape, sigma): """ Run ``sep.winpos`` with the 'center' pixel overlap (``subpix=1``), matching the ``SourceCatalog`` default. Parameters ---------- scene : dict The scene from ``make_scene``. shape : tuple of `~numpy.ndarray` The ``(x, y, a, b, theta)`` arrays from ``sep_shape_inputs``. sigma : `~numpy.ndarray` The per-source window sigma. Returns ------- x, y : `~numpy.ndarray` The windowed centroid. """ x0, y0 = shape[0], shape[1] x, y, _ = sep.winpos(scene['data'], x0, y0, sigma, subpix=1) return np.asarray(x), np.asarray(y) def run_sep_sum_circle(scene, shape, seg_kwargs): """ Run ``sep.sum_circle`` with exact overlap. Parameters ---------- scene : dict The scene from ``make_scene``. shape : tuple of `~numpy.ndarray` The ``(x, y, a, b, theta)`` arrays from ``sep_shape_inputs``. seg_kwargs : dict The SEP ``segmap``/``seg_id`` keyword arguments. Returns ------- flux : `~numpy.ndarray` The circular aperture flux. """ x, y = shape[0], shape[1] flux, _, _ = sep.sum_circle(scene['data'], x, y, RADIUS, err=scene['error'], subpix=0, **seg_kwargs) return np.asarray(flux) def run_sep_chain(scene, scenario): """ Run the full SEP measurement chain. Parameters ---------- scene : dict The scene from ``make_scene``. scenario : dict The scenario (an entry from ``build_scenarios``). """ seg_kwargs = sep_seg_kwargs(scene, scenario) objects = run_sep_extract(scene) shape = sep_shape_inputs(objects) kron_radius, _ = run_sep_kron_radius(scene, shape, seg_kwargs) kron_flux = run_sep_kron_flux(scene, shape, kron_radius, seg_kwargs) half_light = run_sep_flux_radius(scene, shape, kron_radius, kron_flux, seg_kwargs) if not scenario['use_segm']: run_sep_winpos(scene, shape, window_sigma(half_light)) run_sep_sum_circle(scene, shape, seg_kwargs) def run_catalog_chain(catalog): """ Run the full ``SourceCatalog`` measurement chain. Parameters ---------- catalog : `~photutils.segmentation.SourceCatalog` A fresh catalog. """ compute_isophotal(catalog) _ = catalog.kron_radius _ = catalog.kron_flux catalog.flux_radius(FRACTION) _ = catalog.centroid_win catalog.circular_photometry(RADIUS) def _check(name, phot, ref, *, rtol, atol, exact=False): """ Compare photutils and SEP values and print one validation row. Parameters ---------- name : str The row label. phot, ref : `~numpy.ndarray` The photutils and SEP values. rtol, atol : float The tolerances for `~numpy.testing.assert_allclose`. exact : bool, optional Whether to require exact equality (integer properties). Returns ------- ok : bool Whether the comparison passed. """ phot = np.asarray(phot, dtype=float) ref = np.asarray(ref, dtype=float) finite = np.isfinite(phot) & np.isfinite(ref) n_nan_mismatch = np.count_nonzero(np.isfinite(phot) != np.isfinite(ref)) diff = np.abs(phot[finite] - ref[finite]) max_abs = diff.max() if diff.size else 0.0 scale = np.maximum(np.abs(ref[finite]), 1e-12) max_rel = (diff / scale).max() if diff.size else 0.0 ok = n_nan_mismatch == 0 try: if exact: assert_array_equal(phot[finite], ref[finite]) else: assert_allclose(phot[finite], ref[finite], rtol=rtol, atol=atol) except AssertionError: ok = False status = 'ok ' if ok else 'FAIL' print(f' {name:26s} {status} n={finite.sum():5d} ' f'max abs diff {max_abs:.2e} max rel diff {max_rel:.2e}' + (f' ({n_nan_mismatch} NaN mismatches)' if n_nan_mismatch else '')) return ok def _check_flag(name, phot, ref, *, superset=False): """ Compare a photutils flag with its SEP equivalent and print one validation row. Parameters ---------- name : str The ``SourceCatalog`` flag name. phot, ref : `~numpy.ndarray` (bool) The photutils and SEP flags. superset : bool, optional Whether photutils may flag sources that SEP does not. If `False`, the flags must be identical. Returns ------- ok : bool Whether the comparison passed. """ n_phot_only = np.count_nonzero(phot & ~ref) n_sep_only = np.count_nonzero(~phot & ref) ok = n_sep_only == 0 and (superset or n_phot_only == 0) status = 'ok ' if ok else 'FAIL' print(f' {name + " flag":26s} {status} n={phot.size:5d} ' f'photutils {np.count_nonzero(phot)} SEP {np.count_nonzero(ref)} ' f'photutils only {n_phot_only} SEP only {n_sep_only}') return ok def validate(scene, scenarios): """ Validate the ``SourceCatalog`` properties against SEP. Parameters ---------- scene : dict The scene from ``make_scene``. scenarios : list of dict The scenarios from ``build_scenarios``. Returns ------- n_fail : int The number of failed checks. """ n_fail = 0 print('\n== Validation ==') objects = run_sep_extract(scene) shape = sep_shape_inputs(objects) for scenario in scenarios: print(f'\nScenario: {scenario["name"]}') seg_kwargs = sep_seg_kwargs(scene, scenario) catalog = make_catalog(scene, scenario['method']) if len(objects) != catalog.n_labels: print(f' FAIL: SEP extracted {len(objects)} objects for ' f'{catalog.n_labels} labels') n_fail += 1 continue if scenario is scenarios[0]: # The isophotal properties do not depend on the # aperture_mask_method for name, field in ISOPHOTAL_PROPERTIES: ok = _check(name, property_values(catalog, name), sep_values(objects, field), rtol=SEP_RTOL, atol=SEP_ATOL, exact=name in INTEGER_PROPERTIES) n_fail += not ok else: print(' (isophotal properties are independent of the ' 'aperture_mask_method; see the first scenario)') kron_radius, raw = run_sep_kron_radius(scene, shape, seg_kwargs) n_unclipped = np.count_nonzero(np.isfinite(kron_radius) & (raw == kron_radius)) ok = _check(f'kron_radius ({n_unclipped} unclipped)', property_values(catalog, 'kron_radius'), kron_radius, rtol=SEP_RTOL, atol=SEP_ATOL) n_fail += not ok kron_flux = run_sep_kron_flux(scene, shape, kron_radius, seg_kwargs) ok = _check('kron_flux', catalog.kron_flux, kron_flux, rtol=SEP_RTOL, atol=SEP_ATOL) n_fail += not ok catalog_flags = np.atleast_1d(catalog.flags) sep_flags = run_sep_kron_flags(scene, shape, seg_kwargs) for name, sep_flag in sep_flags.items(): bit = getattr(SEGMENTATION_FLAGS, name.upper()) ok = _check_flag(name, (catalog_flags & bit) > 0, sep_flag, superset=name == 'kron_partial_overlap') n_fail += not ok half_light = catalog.flux_radius(FRACTION).value sep_half_light = run_sep_flux_radius(scene, shape, kron_radius, kron_flux, seg_kwargs) ok = _check(f'flux_radius({FRACTION}) [binned]', half_light, sep_half_light, rtol=FLUX_RADIUS_RTOL, atol=SEP_ATOL) n_fail += not ok if scenario['use_segm']: print(f' {"centroid_win":26s} n/a (sep.winpos has no ' 'segmap support)') else: xwin, ywin = run_sep_winpos(scene, shape, window_sigma(half_light)) finite = np.isfinite(half_light) ok = _check('x_centroid_win [phot sigma]', np.asarray(catalog.x_centroid_win)[finite], xwin[finite], rtol=0.0, atol=CENTROID_WIN_ATOL) n_fail += not ok ok = _check('y_centroid_win [phot sigma]', np.asarray(catalog.y_centroid_win)[finite], ywin[finite], rtol=0.0, atol=CENTROID_WIN_ATOL) n_fail += not ok flux, _ = catalog.circular_photometry(RADIUS) ok = _check(f'circular_photometry({RADIUS:g})', flux, run_sep_sum_circle(scene, shape, seg_kwargs), rtol=SEP_RTOL, atol=SEP_ATOL) n_fail += not ok result = 'ALL PASS' if n_fail == 0 else f'{n_fail} FAILURE(S)' print(f'\nValidation result: {result}') return n_fail def make_degenerate_scene(): """ Make a small noiseless scene of unresolved and thin sources. Returns ------- scene : dict The ``data``, ``error``, and int32 ``segm32`` arrays and the source ``names`` in label order. """ shape = (60, 200) data = np.zeros(shape) segm = np.zeros(shape, dtype=np.int32) names = [] def add_source(name, pixels, values): """ Add a source with the next label from its pixels and values. Parameters ---------- name : str The source name. pixels : list of tuple The ``(y, x)`` pixel indices of the source segment. values : list of float The data value of each pixel. """ names.append(name) for (ypix, xpix), value in zip(pixels, values, strict=True): data[ypix, xpix] = value segm[ypix, xpix] = len(names) profile = [10.0, 20.0, 40.0, 60.0, 80.0, 60.0, 40.0, 20.0, 10.0] add_source('2 px horizontal', [(10, 30), (10, 31)], [50.0, 30.0]) add_source('2 px diagonal', [(10, 50), (11, 51)], [50.0, 30.0]) add_source('1x9 line', [(10, 70 + i) for i in range(9)], profile) add_source('9 px line at 45 deg', [(5 + i, 95 + i) for i in range(9)], profile) add_source('2x2 block', [(10, 120), (10, 121), (11, 120), (11, 121)], [50.0, 40.0, 30.0, 20.0]) # The minor-axis variance is below 1/12 but the determinant is # above the SEP singularity limit pixels = [(ypix, xpix) for ypix in (9, 10, 11) for xpix in (144, 145, 146)] add_source('sharp 3x3', pixels, [0.5, 4.0, 0.5, 10.0, 100.0, 10.0, 0.5, 4.0, 0.5]) yy, xx = np.mgrid[:shape[0], :shape[1]] for name, model in ( ('thin tilted gaussian', Gaussian2D(100.0, 170.4, 12.7, 0.3, 2.5, theta=0.5)), ('resolved gaussian', Gaussian2D(100.0, 30.2, 40.6, 3.0, 1.5, theta=0.7))): image = model(xx, yy) pixels = [tuple(idx) for idx in np.argwhere(image > 1.0)] add_source(name, pixels, [image[idx] for idx in pixels]) return {'data': data, 'error': np.ones(shape), 'segm32': segm, 'names': names} def validate_degenerate(): """ Compare SourceCatalog with SEP on unresolved and thin sources. The quantities that both packages define the same way are checked strictly. The shape properties and centroid errors of singular sources differ by design and are only printed. Returns ------- n_fail : int The number of failed strict checks. """ scene = make_degenerate_scene() catalog = SourceCatalog(scene['data'], SegmentationImage(scene['segm32']), error=scene['error']) objects, _ = sep.extract(scene['data'], DEGENERATE_THRESHOLD, err=scene['error'], filter_kernel=None, minarea=1, clean=False, segmentation_map=scene['segm32']) singular = (catalog.flags & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE) > 0 sep_singular = (objects['flag'] & sep.OBJ_SINGU) > 0 either = singular | sep_singular print('\n== Degenerate sources (unresolved and thin) ==') print('\nStrict checks, all sources') n_fail = 0 for name, field in (('x_centroid', 'x'), ('y_centroid', 'y'), ('segment_area', 'npix')): ok = _check(name, property_values(catalog, name), sep_values(objects, field), rtol=SEP_RTOL, atol=SEP_ATOL) n_fail += not ok # Both packages floor the minor axis of a source that SEP flags for label, values in ( ('minor-axis floor [phot]', property_values(catalog, 'semiminor_axis')), ('minor-axis floor [SEP]', sep_values(objects, 'b'))): ok = _check(label, values[sep_singular], np.full(sep_singular.sum(), MIN_SEMIMINOR_AXIS), rtol=SEP_RTOL, atol=SEP_ATOL) n_fail += not ok # The SEP determinant test is implied by the minor-axis variance test ok = bool(np.all(singular[sep_singular])) print(f' {"SEP singular => phot":26s} {"ok " if ok else "FAIL"} ' f'SEP flags {sep_singular.sum()}, photutils flags ' f'{singular.sum()} of {len(singular)}') n_fail += not ok print('\nStrict checks, sources that neither package flags as singular') for name, field in ISOPHOTAL_PROPERTIES: if name in ('x_centroid', 'y_centroid', 'segment_area'): continue ok = _check(name, property_values(catalog, name)[~either], sep_values(objects, field)[~either], rtol=SEP_RTOL, atol=SEP_ATOL) n_fail += not ok print('\nKnown differences for singular sources (informational; P = ' 'photutils, S = SEP)') columns = (('semimajor_axis', 'a', 'a'), ('semiminor_axis', 'b', 'b'), ('covariance_xx', 'x2', 'cov_xx'), ('covariance_yy', 'y2', 'cov_yy'), ('covariance_xy', 'xy', 'cov_xy'), ('x_centroid_err', 'errx2', 'x_err'), ('y_centroid_err', 'erry2', 'y_err')) header = f' {"source":22s} {"flag":>5s} ' + ''.join( f'{label:>9s}' for _, _, label in columns) print(header) print(' ' + '-' * (len(header) - 2)) for idx in np.flatnonzero(either): for tag, flagged, getter in ( ('P', singular, lambda name, _: property_values(catalog, name)), ('S', sep_singular, lambda _, field: sep_values(objects, field))): label = scene['names'][idx] if tag == 'P' else '' row = ''.join(f'{getter(name, field)[idx]:9.4f}' for name, field, _ in columns) print(f' {label:22s} {tag} {"yes" if flagged[idx] else "no":>3s}' f' {row}') result = 'ALL PASS' if n_fail == 0 else f'{n_fail} FAILURE(S)' print(f'\nDegenerate strict-check result: {result}') return n_fail def time_step(make_fresh_catalog, step, *, prepare=None, repeats=3): """ Return the best time of ``step`` on fresh catalogs whose prerequisite properties are computed by ``prepare``. Parameters ---------- make_fresh_catalog : callable The zero-argument callable returning a fresh catalog. step : callable The callable taking the catalog and computing the timed step. prepare : callable or `None`, optional The callable taking the catalog and computing the prerequisites outside of the timed region. repeats : int, optional The number of repeats (the best time is kept). Returns ------- result : float The best wall-clock time in seconds. """ best = np.inf for _ in range(repeats): catalog = make_fresh_catalog() if prepare is not None: prepare(catalog) t0 = time.perf_counter() step(catalog) best = min(best, time.perf_counter() - t0) return best def benchmark(scene, scenarios, *, repeats=3): """ Benchmark the ``SourceCatalog`` measurements against SEP. Parameters ---------- scene : dict The scene from ``make_scene``. scenarios : list of dict The scenarios from ``build_scenarios``. repeats : int, optional The number of repeats for each timing (best time is kept). """ n_src = scene['segm'].n_labels shape_ = scene['data'].shape print(f'\n== Benchmark (best of {repeats}, {n_src} segments, ' f'{shape_[0]}x{shape_[1]} image; step times on a catalog with ' 'the prerequisites computed) ==') # The SEP inputs for the per-step timings objects = run_sep_extract(scene) shape = sep_shape_inputs(objects) header = (f'{"benchmark":34s} {"photutils":>11s} {"SEP":>11s} ' f'{"phot/SEP":>10s}') for scenario in scenarios: seg_kwargs = sep_seg_kwargs(scene, scenario) kron_radius, _ = run_sep_kron_radius(scene, shape, seg_kwargs) kron_flux = run_sep_kron_flux(scene, shape, kron_radius, seg_kwargs) half_light = run_sep_flux_radius(scene, shape, kron_radius, kron_flux, seg_kwargs) sigma = window_sigma(half_light) def make_fresh_catalog(method=scenario['method']): return make_catalog(scene, method) def warm_kron_radius(catalog): compute_isophotal(catalog) _ = catalog.kron_radius def warm_kron_flux(catalog): warm_kron_radius(catalog) _ = catalog.kron_flux def warm_flux_radius(catalog): warm_kron_flux(catalog) catalog.flux_radius(FRACTION) rows = [ ('isophotal properties (extract)', None, compute_isophotal, lambda: run_sep_extract(scene)), ('kron_radius', compute_isophotal, lambda cat: cat.kron_radius, lambda sk=seg_kwargs: run_sep_kron_radius(scene, shape, sk)), ('kron_flux', warm_kron_radius, lambda cat: cat.kron_flux, lambda kr=kron_radius, sk=seg_kwargs: run_sep_kron_flux(scene, shape, kr, sk)), (f'flux_radius({FRACTION})', warm_kron_flux, lambda cat: cat.flux_radius(FRACTION), lambda kr=kron_radius, kf=kron_flux, sk=seg_kwargs: run_sep_flux_radius(scene, shape, kr, kf, sk)), ('centroid_win', warm_flux_radius, lambda cat: cat.centroid_win, None if scenario['use_segm'] else lambda sig=sigma: run_sep_winpos(scene, shape, sig)), (f'circular_photometry({RADIUS:g})', lambda cat: cat.x_centroid, lambda cat: cat.circular_photometry(RADIUS), lambda sk=seg_kwargs: run_sep_sum_circle(scene, shape, sk)), ('full chain (cold catalog)', None, run_catalog_chain, lambda sc=scenario: run_sep_chain(scene, sc)), ] print(f'\nScenario: {scenario["name"]}') print(header) print('-' * len(header)) for name, prepare, step, sep_step in rows: t_phot = time_step(make_fresh_catalog, step, prepare=prepare, repeats=repeats) if sep_step is None: sep_ms = f'{"--":>11s}' ratio = f'{"--":>10s}' else: t_sep = time_best(sep_step, repeats=repeats) sep_ms = f'{t_sep * 1e3:11.2f}' ratio = f'{t_phot / t_sep:10.2f}' print(f'{name:34s} {t_phot * 1e3:11.2f} {sep_ms} {ratio}') print('\n(times in ms; phot/SEP = SourceCatalog / SEP runtime ratio, ' 'lower is faster; -- = no SEP analogue)') def main(): """ Run the SourceCatalog versus SEP comparison. """ parser = argparse.ArgumentParser( description='Benchmark and validate SourceCatalog against SEP.') parser.add_argument('--n-sources', type=int, default=1000, help='total number of Gaussian sources in the ' 'image (default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'validate', 'degenerate', 'benchmark'], help='which part to run (default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if not HAS_SEP: print(f'sep is not available ({SEP_IMPORT_ERROR}); nothing to ' 'compare') sys.exit(1) print(f'sep {sep.__version__}') scene = make_scene(args.n_sources, seed=args.seed) # SEP needs room for every segment pixel on its pixel stack sep.set_extract_pixstack(max(sep.get_extract_pixstack(), scene['data'].size)) scenarios = build_scenarios() n_fail = 0 if args.which in ('all', 'validate'): n_fail = validate(scene, scenarios) if args.which in ('all', 'degenerate'): n_fail += validate_degenerate() if args.which in ('all', 'benchmark'): benchmark(scene, scenarios, repeats=args.repeats) if n_fail: sys.exit(1) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_centroids.py000077500000000000000000000210531526452531300234340ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.centroids subpackage. The benchmarks cover the per-call cost of the single-source centroid functions (centroid_com, centroid_quadratic, centroid_1dg, and centroid_2dg) and centroid_sources at many positions. Run ``python benchmarks/bench_centroids.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.centroids import (centroid_1dg, centroid_2dg, centroid_com, centroid_quadratic, centroid_sources) CENTROID_FUNCS = [('centroid_com', centroid_com), ('centroid_quadratic', centroid_quadratic), ('centroid_1dg', centroid_1dg), ('centroid_2dg', centroid_2dg)] # Centroid functions that accept an error keyword ERROR_FUNCS = (centroid_1dg, centroid_2dg) def make_source_cutout(size, *, fwhm=4.0, amplitude=100.0, noise_std=1.0, seed=0): """ Return a cutout image containing a single Gaussian source. Parameters ---------- size : int The cutout size; the cutout is ``(size, size)``. fwhm : float, optional The FWHM of the Gaussian source in pixels. amplitude : float, optional The amplitude of the Gaussian source. noise_std : float, optional The standard deviation of the Gaussian noise. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The cutout image. error : 2D `~numpy.ndarray` The error array. """ rng = np.random.default_rng(seed) yy, xx = np.mgrid[0:size, 0:size] xc = yc = (size - 1) / 2.0 sigma = fwhm * gaussian_fwhm_to_sigma model = Gaussian2D(amplitude, xc, yc, sigma, sigma) data = model(xx, yy) + rng.normal(0.0, noise_std, (size, size)) error = np.full(data.shape, noise_std) return data, error def make_sources_image(n_sources, *, spacing=25, fwhm=4.0, amplitude=100.0, noise_std=1.0, seed=0): """ Return an image with a grid of Gaussian sources and their positions. Parameters ---------- n_sources : int The number of sources. The image is a square grid of ``spacing``-sized cells with one source per cell, so the image size is ``ceil(sqrt(n_sources)) * spacing`` per side. spacing : int, optional The grid cell size in pixels. fwhm : float, optional The FWHM of the Gaussian sources in pixels. amplitude : float, optional The amplitude of the Gaussian sources. noise_std : float, optional The standard deviation of the Gaussian noise. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. xpos, ypos : 1D `~numpy.ndarray` The source positions. """ n_grid = int(np.ceil(np.sqrt(n_sources))) size = n_grid * spacing rng = np.random.default_rng(seed) data = rng.normal(0.0, noise_std, (size, size)) sigma = fwhm * gaussian_fwhm_to_sigma yy, xx = np.mgrid[0:spacing, 0:spacing] xpos = np.empty(n_sources) ypos = np.empty(n_sources) for i in range(n_sources): gy, gx = divmod(i, n_grid) xc = (spacing - 1) / 2.0 + rng.uniform(-0.5, 0.5) yc = (spacing - 1) / 2.0 + rng.uniform(-0.5, 0.5) model = Gaussian2D(amplitude, xc, yc, sigma, sigma) x0 = gx * spacing y0 = gy * spacing data[y0:y0 + spacing, x0:x0 + spacing] += model(xx, yy) xpos[i] = x0 + xc ypos[i] = y0 + yc return data, xpos, ypos def bench_functions(*, cutout_size=21, n_iter=200, repeats=3, seed=0): """ Benchmark the per-call cost of the single-source centroid functions. Each function is called on the same single-source cutout; the Gaussian fitting functions are also timed with an error array. Parameters ---------- cutout_size : int, optional The cutout size; the cutout is ``(cutout_size, cutout_size)``. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== Single-source centroid functions ' f'({cutout_size}x{cutout_size} cutout, per-call time) ==') print(f'{"function":>22}{"no error":>12}{"with error":>13}') data, error = make_source_cutout(cutout_size, seed=seed) def run(func, n_iter, **kwargs): """ Call the centroid function repeatedly on the cutout. Parameters ---------- func : callable The centroid function. n_iter : int The number of calls. **kwargs : dict, optional Keyword arguments passed to ``func``. """ for _ in range(n_iter): func(data, **kwargs) for name, func in CENTROID_FUNCS: t_call = time_best(partial(run, func, n_iter), repeats=repeats) / n_iter cells = f'{t_call * 1e6:>10.1f}us' if func in ERROR_FUNCS: t_err = time_best(partial(run, func, n_iter, error=error), repeats=repeats) / n_iter cells += f'{t_err * 1e6:>11.1f}us' else: cells += f'{"n/a":>13}' print(f'{name:>22}{cells}') def bench_centroid_sources(*, n_sources=1000, box_size=11, repeats=3, seed=0): """ Benchmark centroid_sources at many positions for each centroid function. Parameters ---------- n_sources : int, optional The number of sources. box_size : int, optional The cutout box size used for each source. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, xpos, ypos = make_sources_image(n_sources, seed=seed) print(f'\n== centroid_sources ({n_sources} sources, ' f'{data.shape[0]}x{data.shape[1]} image, box_size={box_size}) ==') print(f'{"function":>22}{"time":>12}') for name, func in CENTROID_FUNCS: bench = partial(centroid_sources, data, xpos, ypos, box_size=box_size, centroid_func=func) t_best = time_best(bench, repeats=repeats) print(f'{name:>22}{f"{t_best:.4f}s":>12}') def main(): """ Run the photutils.centroids benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.centroids subpackage.') parser.add_argument('--cutout-size', type=int, default=21, help='single-source cutout size ' '(default: %(default)s)') parser.add_argument('--n-sources', type=int, default=1000, help='number of sources for centroid_sources ' '(default: %(default)s)') parser.add_argument('--box-size', type=int, default=11, help='centroid_sources cutout box size ' '(default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'functions', 'sources'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'functions'): bench_functions(cutout_size=args.cutout_size, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'sources'): bench_centroid_sources(n_sources=args.n_sources, box_size=args.box_size, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_datasets.py000077500000000000000000000311551526452531300232560ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.datasets subpackage. The benchmarks cover the scaling of ``make_model_image`` with the number of sources, the model discretization methods, the scaling of ``make_model_params`` with the number of sources, the noise functions, the WCS factories, and the example-image functions. Run ``python benchmarks/bench_datasets.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from astropy.modeling.models import Gaussian2D from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.datasets import (apply_poisson_noise, make_4gaussians_image, make_100gaussians_image, make_gwcs, make_model_image, make_model_params, make_noise_image, make_wcs) from photutils.utils._optional_deps import HAS_GWCS MODEL_SHAPE = (25, 25) def make_source_params(shape, n_sources): """ Return a table of random Gaussian2D source parameters. Parameters ---------- shape : 2-tuple of int The shape of the target image. n_sources : int The number of sources to generate. Returns ------- params : `~astropy.table.QTable` The table of model parameters. """ return make_model_params(shape, n_sources, x_name='x_mean', y_name='y_mean', min_separation=1, border_size=10, amplitude=(50, 200), x_stddev=(1, 3), y_stddev=(1, 3), theta=(0, np.pi), seed=0) def run_model_image(shape, params, n_iter, **kwargs): """ Render a model image repeatedly. Parameters ---------- shape : 2-tuple of int The shape of the output image. params : `~astropy.table.QTable` The table of model parameters. n_iter : int The number of calls. **kwargs : dict, optional Additional keyword arguments passed to ``make_model_image`` (e.g., ``discretize_method``). """ model = Gaussian2D() for _ in range(n_iter): make_model_image(shape, model, params, model_shape=MODEL_SHAPE, x_name='x_mean', y_name='y_mean', **kwargs) def bench_model_image(*, size=500, n_sources_list=(100, 400, 1600), n_iter=3, repeats=3): """ Benchmark make_model_image versus the number of sources. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources_list : tuple of int, optional The numbers of sources to render. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ shape = (size, size) print(f'\n== make_model_image ({size}x{size} image, ' f'{MODEL_SHAPE[0]}x{MODEL_SHAPE[1]} model shape, ' 'per-call time) ==') print(f'{"n_sources":>12}{"time":>12}{"per-source":>14}') for n_sources in n_sources_list: params = make_source_params(shape, n_sources) bench = partial(run_model_image, shape, params, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter t_src = t_call / len(params) print(f'{n_sources:>12}{f"{t_call * 1e3:.2f}ms":>12}' f'{f"{t_src * 1e6:.1f}us":>14}') def bench_discretize(*, size=300, n_sources=200, n_iter=3, repeats=3): """ Benchmark make_model_image for each discretization method. The 'integrate' method is omitted because it is extremely slow. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources : int, optional The number of sources to render. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ shape = (size, size) params = make_source_params(shape, n_sources) print(f'\n== discretization methods ({size}x{size} image, ' f'{len(params)} sources, per-call time) ==') print(f'{"method":>14}{"time":>12}') for method in ('center', 'interp', 'oversample'): bench = partial(run_model_image, shape, params, n_iter, discretize_method=method) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{method:>14}{f"{t_call * 1e3:.2f}ms":>12}') def run_model_params(shape, n_sources, min_separation, n_iter): """ Generate a table of source parameters repeatedly. Parameters ---------- shape : 2-tuple of int The shape of the target image. n_sources : int The number of sources to generate. min_separation : float The minimum separation between source centers. n_iter : int The number of calls. """ for _ in range(n_iter): make_model_params(shape, n_sources, min_separation=min_separation, flux=(100, 500), seed=0) def bench_model_params(*, size=500, n_sources_list=(100, 400, 1600), n_iter=10, repeats=3): """ Benchmark make_model_params versus the number of sources. The timing includes the minimum-separation (KDTree) filtering of the random coordinates. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources_list : tuple of int, optional The numbers of sources to generate. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ shape = (size, size) print(f'\n== make_model_params ({size}x{size} image, per-call ' 'time) ==') print(f'{"n_sources":>12}{"min_sep=1":>14}{"min_sep=5":>14}') for n_sources in n_sources_list: cells = '' for min_separation in (1, 5): bench = partial(run_model_params, shape, n_sources, min_separation, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.2f}ms":>14}' print(f'{n_sources:>12}{cells}') def run_noise(func, n_iter, **kwargs): """ Call a noise function repeatedly. Parameters ---------- func : callable The zero-argument noise function variant. n_iter : int The number of calls. **kwargs : dict, optional Keyword arguments passed to ``func``. """ for _ in range(n_iter): func(**kwargs) def bench_noise(*, size=1000, n_iter=10, repeats=3): """ Benchmark the noise functions. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ shape = (size, size) data = np.full(shape, 100.0) variants = ( ('make_noise_image (gaussian)', partial(make_noise_image, shape, distribution='gaussian', mean=0.0, stddev=2.0, seed=0)), ('make_noise_image (poisson)', partial(make_noise_image, shape, distribution='poisson', mean=5.0, seed=0)), ('apply_poisson_noise', partial(apply_poisson_noise, data, seed=0)), ) print(f'\n== noise functions ({size}x{size} image, per-call ' 'time) ==') print(f'{"variant":>30}{"time":>12}') for label, func in variants: bench = partial(run_noise, func, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{label:>30}{f"{t_call * 1e3:.2f}ms":>12}') def bench_wcs(*, size=1000, n_iter=100, repeats=3): """ Benchmark the WCS factory functions. The gWCS benchmark is skipped if the optional gwcs package is not installed. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ shape = (size, size) variants = [('make_wcs', partial(make_wcs, shape))] if HAS_GWCS: variants.append(('make_gwcs', partial(make_gwcs, shape))) print('\n== WCS factories (per-call time) ==') print(f'{"function":>12}{"time":>12}') for label, func in variants: bench = partial(run_noise, func, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{label:>12}{f"{t_call * 1e3:.3f}ms":>12}') if not HAS_GWCS: print('make_gwcs skipped (gwcs is not installed)') def bench_examples(*, n_iter=3, repeats=3): """ Benchmark the example-image functions. Parameters ---------- n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ variants = ( ('make_4gaussians_image', make_4gaussians_image), ('make_100gaussians_image', make_100gaussians_image), ) print('\n== example images (per-call time) ==') print(f'{"function":>26}{"time":>12}') for label, func in variants: bench = partial(run_noise, func, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{label:>26}{f"{t_call * 1e3:.2f}ms":>12}') def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'100,400,1600'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def main(): """ Run the photutils.datasets benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.datasets subpackage.') parser.add_argument('--size', type=int, default=500, help='image size for the model-image and ' 'model-params benchmarks ' '(default: %(default)s)') parser.add_argument('--n-sources-list', type=parse_int_list, default=[100, 400, 1600], help='comma-separated numbers of sources for ' 'the scaling benchmarks ' '(default: 100,400,1600)') parser.add_argument('--n-iter', type=int, default=3, help='number of calls per timing ' '(default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'model-image', 'discretize', 'model-params', 'noise', 'wcs', 'examples'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'model-image'): bench_model_image(size=args.size, n_sources_list=args.n_sources_list, n_iter=args.n_iter, repeats=args.repeats) if args.which in ('all', 'discretize'): bench_discretize(n_iter=args.n_iter, repeats=args.repeats) if args.which in ('all', 'model-params'): bench_model_params(size=args.size, n_sources_list=args.n_sources_list, n_iter=args.n_iter * 3, repeats=args.repeats) if args.which in ('all', 'noise'): bench_noise(n_iter=args.n_iter * 3, repeats=args.repeats) if args.which in ('all', 'wcs'): bench_wcs(n_iter=args.n_iter * 30, repeats=args.repeats) if args.which in ('all', 'examples'): bench_examples(n_iter=args.n_iter, repeats=args.repeats) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_deblend.py000077500000000000000000000773371526452531300230570ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for source deblending (deblend_sources). The benchmarks cover the two axes along which deblending is known to be slow: * many sources: an image with a grid of blended Gaussian-source pairs, sweeping the number of sources (per-source Python and numpy call overhead dominates) * large sources: a single connected segment made of a broad Gaussian envelope with superposed peaks, sweeping the segment size and the number of peaks (per-level full-cutout work and the iterative watershed contrast loop dominate) A per-stage breakdown of the single-source deblending pipeline (multithresholding, marker building, watershed, contrast loop) and a cProfile mode are also provided for bottleneck analysis. An optional comparison against the SEP package (``--which sep``, which requires ``sep``) times ``sep.extract`` on the same scenes. SEP fuses detection and deblending in one call and cannot deblend an existing segmentation map, so the comparison is end to end, with a detect-only reference row for each package. Run ``python benchmarks/bench_deblend.py --help`` to see the available options. """ import argparse import cProfile import pstats import sys import warnings from functools import partial import numpy as np from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma from bench_helpers import (add_memory_argument, format_sweep_cells, parse_int_list, parse_thread_counts, print_environment, set_memory_mode, time_best) from bench_segmentation import N_PIXELS, THRESHOLD, make_inputs from photutils.segmentation import detect_sources, make_2dgaussian_kernel from photutils.segmentation._deblend_reference import _SingleSourceDeblender from photutils.segmentation.deblend import _DeblendParams, deblend_sources from photutils.segmentation.utils import _make_binary_structure from photutils.utils.exceptions import DeblendWarning try: import sep HAS_SEP = True SEP_IMPORT_ERROR = None except ImportError as exc: HAS_SEP = False SEP_IMPORT_ERROR = str(exc) BLEND_THRESHOLD = 0.5 ENVELOPE_AMPLITUDE = 5.0 PEAK_FWHM = 8.0 # The SEP deblending defaults, applied to both packages in the SEP # comparison SEP_N_LEVELS = 32 SEP_CONTRAST = 0.005 def make_blended_image(size, n_peaks, *, amp_range=(3.0, 100.0), seed=0): """ Return an image containing a single large blended source. The source is a broad Gaussian envelope with ``n_peaks`` compact Gaussian peaks superposed on it, so that detection at ``BLEND_THRESHOLD`` yields one large connected segment that deblending must split. Parameters ---------- size : int The image size; the image is ``(size, size)``. The envelope sigma is ``size / 6``, so the segment area scales with the image area. n_peaks : int The number of compact peaks placed within one envelope sigma of the center. amp_range : tuple of float, optional The (min, max) peak amplitudes. The amplitudes are logarithmically spaced over this range. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. """ rng = np.random.default_rng(seed) data = rng.normal(0.0, 0.01, (size, size)) cen = (size - 1) / 2.0 sigma_env = size / 6.0 yy, xx = np.mgrid[0:size, 0:size] envelope = Gaussian2D(ENVELOPE_AMPLITUDE, cen, cen, sigma_env, sigma_env) data += envelope(xx, yy) sigma = PEAK_FWHM * gaussian_fwhm_to_sigma half = int(np.ceil(4.0 * sigma)) yy_cut, xx_cut = np.mgrid[0:2 * half + 1, 0:2 * half + 1] amplitudes = np.geomspace(amp_range[1], amp_range[0], n_peaks) radii = sigma_env * np.sqrt(rng.uniform(0.0, 1.0, n_peaks)) angles = rng.uniform(0.0, 2.0 * np.pi, n_peaks) for amplitude, radius, angle in zip(amplitudes, radii, angles, strict=True): xc = cen + radius * np.cos(angle) yc = cen + radius * np.sin(angle) x0 = int(xc) - half y0 = int(yc) - half model = Gaussian2D(amplitude, xc - x0, yc - y0, sigma, sigma) data[y0:y0 + 2 * half + 1, x0:x0 + 2 * half + 1] += model(xx_cut, yy_cut) return data def make_blended_inputs(size, n_peaks, *, amp_range=(3.0, 100.0), seed=0): """ Return the image and segmentation image for a single large blended source. Parameters ---------- size : int The image size; the image is ``(size, size)``. n_peaks : int The number of compact peaks. amp_range : tuple of float, optional The (min, max) peak amplitudes. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. segm : `~photutils.segmentation.SegmentationImage` The segmentation image containing a single label. """ data = make_blended_image(size, n_peaks, amp_range=amp_range, seed=seed) segm = detect_sources(data, BLEND_THRESHOLD, N_PIXELS) if segm.n_labels != 1: msg = (f'expected a single blended segment, got ' f'{segm.n_labels}') raise ValueError(msg) return data, segm def n_fallbacks(segm): """ Return the number of deblending mode fallbacks recorded in a deblended segmentation image. Parameters ---------- segm : `~photutils.segmentation.SegmentationImage` The deblended segmentation image. Returns ------- result : int The total number of input labels whose deblending mode fell back to linear. """ return (len(segm.info.get('non_positive_min_labels', ())) + len(segm.info.get('too_many_markers_labels', ()))) def bench_many_sources(*, n_sources_sweep=(500, 1000, 2000, 4000), repeats=3, seed=0): """ Benchmark deblend_sources versus the number of sources. Each image contains a grid of blended Gaussian-source pairs, so the number of segments is half the number of sources and every segment deblends into two sources. The detect_sources time is included as a reference point. Parameters ---------- n_sources_sweep : tuple of int, optional The numbers of Gaussian sources. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print('\n== deblend_sources: many small sources ==') header = (f'{"benchmark":>24}{"segments":>10}{"time":>12}' f'{"ms/segment":>12}{"n_labels":>10}') for n_sources in n_sources_sweep: _, data, segm = make_inputs(n_sources, seed=seed) print(f'\n-- {n_sources} sources, {segm.n_labels} segments, ' f'{data.shape[0]}x{data.shape[1]} image --') print(header) bench = partial(detect_sources, data, THRESHOLD, N_PIXELS) t_best = time_best(bench, repeats=repeats) per_segment = 1000.0 * t_best / segm.n_labels print(f'{"detect_sources (ref)":>24}{segm.n_labels:>10}' f'{f"{t_best:.4f}s":>12}{per_segment:>12.3f}' f'{segm.n_labels:>10}') for mode in ('linear', 'exponential', 'sinh'): bench = partial(deblend_sources, data, segm, N_PIXELS, mode=mode) segm_deblended = bench() t_best = time_best(bench, repeats=repeats) per_segment = 1000.0 * t_best / segm.n_labels name = f'mode={mode}' print(f'{name:>24}{segm.n_labels:>10}{f"{t_best:.4f}s":>12}' f'{per_segment:>12.3f}{segm_deblended.n_labels:>10}') def bench_large_source(*, size_sweep=(250, 500, 1000, 2000), n_peaks=8, repeats=3, seed=0): """ Benchmark deblend_sources versus the size of a single segment. Each image contains one connected segment (a Gaussian envelope with bright peaks) whose area scales with the image area. The contrast is set to 0 so that no markers are removed and the sweep measures the pure multithreshold plus watershed scaling. Parameters ---------- size_sweep : tuple of int, optional The image sizes. n_peaks : int, optional The number of compact peaks in the segment. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== deblend_sources: single large segment ({n_peaks} peaks, ' 'contrast=0) ==') print(f'{"benchmark":>28}{"seg area":>10}{"time":>12}{"n_labels":>10}') for size in size_sweep: data, segm = make_blended_inputs(size, n_peaks, amp_range=(50.0, 100.0), seed=seed) area = int(segm.areas[0]) for mode in ('linear', 'exponential'): bench = partial(deblend_sources, data, segm, N_PIXELS, mode=mode, contrast=0.0) segm_deblended = bench() t_best = time_best(bench, repeats=repeats) name = f'size={size}, mode={mode}' print(f'{name:>28}{area:>10}{f"{t_best:.4f}s":>12}' f'{segm_deblended.n_labels:>10}') def bench_many_peaks(*, size=1000, n_peaks_sweep=(10, 25, 50, 100), contrast_sweep=(0.0, 0.001, 0.01, 0.03), repeats=3, seed=0): """ Benchmark deblend_sources versus the number of peaks in one segment. The contrast criterion applies to the flux in each watershed basin (which includes a share of the envelope flux), so larger contrast values remove more markers. Each removal iteration reruns the watershed over the full segment, so comparing the contrast=0 row (a single watershed call) to the larger-contrast rows isolates the cost of the iterative marker-removal loop. The number of watershed calls is at most the difference between the contrast=0 n_labels and the row's n_labels, plus one (batched removal may use fewer). Parameters ---------- size : int, optional The image size. n_peaks_sweep : tuple of int, optional The numbers of peaks. contrast_sweep : tuple of float, optional The contrast values. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== deblend_sources: many peaks in one segment ' f'({size}x{size} image, mode=exponential) ==') print(f'{"benchmark":>32}{"time":>12}{"n_labels":>10}' f'{"fallbacks":>10}') for n_peaks in n_peaks_sweep: data, segm = make_blended_inputs(size, n_peaks, seed=seed) for contrast in contrast_sweep: bench = partial(deblend_sources, data, segm, N_PIXELS, contrast=contrast) segm_deblended = bench() t_best = time_best(bench, repeats=repeats) name = f'n_peaks={n_peaks}, contrast={contrast}' print(f'{name:>32}{f"{t_best:.4f}s":>12}' f'{segm_deblended.n_labels:>10}' f'{n_fallbacks(segm_deblended):>10}') def bench_stages(*, size=1000, n_peaks=25, mode='exponential', contrast=0.001, n_levels=32, repeats=3, seed=0): """ Benchmark the stages of the single-source deblending pipeline. The stages are timed on the segment cutout of a single large blended source, using the private _SingleSourceDeblender class: * constructor: the segment mask and min/max/sum reductions * multithreshold: the ``n_levels`` per-level detection passes of the reference marker construction * make_markers: the multithreshold levels, the level quantization, and the compiled component-tree kernel * watershed: a single watershed call over the cutout * apply_watershed: the reference watershed contrast loop (one watershed call per removed marker) * deblend_source: the full pipeline Parameters ---------- size : int, optional The image size. n_peaks : int, optional The number of compact peaks in the segment. mode : str, optional The mode used for spacing the multithreshold levels. contrast : float, optional The deblending contrast criterion. n_levels : int, optional The number of multithreshold levels. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, segm = make_blended_inputs(size, n_peaks, seed=seed) label = segm.labels[0] slc = segm.slices[0] cutout = data[slc] segment_cutout = segm.data[slc] footprint = _make_binary_structure(2, 8) params = _DeblendParams(N_PIXELS, footprint, n_levels, contrast, mode) def _make_deblender(): return _SingleSourceDeblender(cutout, segment_cutout, label, params) deblender = _make_deblender() markers = deblender.make_markers() n_markers = len(np.unique(markers[markers > 0])) final = deblender.apply_watershed(markers) n_final = len(np.unique(final[final > 0])) n_watershed = n_markers - n_final + 1 from photutils.segmentation._deblend_watershed import deblend_watershed data_neg = np.ascontiguousarray(-cutout, dtype=np.float64) connectivity = 8 if footprint[0, 0] else 4 def _run_constructor(): _make_deblender() def _run_multithreshold(): _make_deblender().multithreshold() def _run_make_markers(): _make_deblender().make_markers() def _run_watershed(): deblend_watershed(data_neg, markers, deblender.segment_mask, connectivity) def _run_apply_watershed(): deblender.apply_watershed(markers) def _run_deblend_source(): _make_deblender().deblend_source() print(f'\n== single-source pipeline stages ({size}x{size} image, ' f'cutout {cutout.shape[0]}x{cutout.shape[1]}, ' f'{n_peaks} peaks, mode={mode}, contrast={contrast}, ' f'n_levels={n_levels}) ==') print(f'{n_markers} markers, {n_final} final labels, ' f'<={n_watershed} watershed calls') print(f'{"stage":>36}{"time":>12}') benchmarks = [ ('constructor (mask + min/max/sum)', _run_constructor), (f'multithreshold ({n_levels} per-level detects)', _run_multithreshold), ('make_markers (component tree)', _run_make_markers), ('watershed (single call)', _run_watershed), ('apply_watershed (contrast loop)', _run_apply_watershed), ('deblend_source (full)', _run_deblend_source), ] for name, func in benchmarks: t_best = time_best(func, repeats=repeats) print(f'{name:>36}{f"{t_best:.4f}s":>12}') def profile_case(name, func, *, limit=20): """ Profile a callable with cProfile and print the top functions. Parameters ---------- name : str The name of the profiled case. func : callable The zero-argument callable to profile. limit : int, optional The number of functions to print. """ profiler = cProfile.Profile() profiler.enable() func() profiler.disable() print(f'\n== cProfile: {name} ==') stats = pstats.Stats(profiler) stats.sort_stats('tottime') stats.print_stats(limit) def bench_profile(*, n_sources=2000, size=1000, n_peaks=25, seed=0): """ Profile deblend_sources for the many-source and large-source scenarios. Parameters ---------- n_sources : int, optional The number of sources for the many-source case. size : int, optional The image size for the large-source case. n_peaks : int, optional The number of peaks for the large-source case. seed : int, optional The random number generator seed. """ _, data, segm = make_inputs(n_sources, seed=seed) profile_case( f'many small sources ({segm.n_labels} segments)', partial(deblend_sources, data, segm, N_PIXELS)) data, segm = make_blended_inputs(size, n_peaks, seed=seed) profile_case( f'single large segment ({size}x{size}, {n_peaks} peaks)', partial(deblend_sources, data, segm, N_PIXELS)) def bench_threads(*, n_sources=4000, size=1000, n_peaks=25, thread_counts=(1, 2, 4, 8), repeats=3, seed=0): """ Benchmark deblend_sources thread scaling. Two regimes are timed: a field of many small blended pairs and a 2x2 grid of large blended sources (whose per-source work is dominated by the GIL-releasing watershed and marker kernels). Parameters ---------- n_sources : int, optional The total number of Gaussian sources for the many-source scene. size : int, optional The tile size for the large-source grid scene. n_peaks : int, optional The number of compact peaks per large source. thread_counts : tuple of int, optional The n_threads values. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ _, data, segm = make_inputs(n_sources, seed=seed) tile = make_blended_image(size, n_peaks, seed=seed) grid = np.empty((2 * size, 2 * size)) for iy in range(2): for ix in range(2): grid[iy * size:(iy + 1) * size, ix * size:(ix + 1) * size] = tile segm_grid = detect_sources(grid, BLEND_THRESHOLD, N_PIXELS) scenes = [ (f'{segm.n_labels} small segments', data, segm), (f'{segm_grid.n_labels} large segments', grid, segm_grid), ] print('\n== deblend_sources: thread scaling ==') header = ''.join(f'{f"{n} thr":>18}' for n in thread_counts) print(f'{"scene":>24}{header}') for name, scene_data, scene_segm in scenes: times = [] for n_threads in thread_counts: bench = partial(deblend_sources, scene_data, scene_segm, N_PIXELS, n_threads=n_threads) times.append(time_best(bench, repeats=repeats)) cells = ''.join(f'{cell:>18}' for cell in format_sweep_cells(times)) print(f'{name:>24}{cells}') def bench_contrast_method(*, n_sources=4000, size=1000, n_peaks=100, repeats=3, seed=0): """ Benchmark the basin and saddle contrast criteria. The saddle criterion selects the markers in the component tree and floods once, while the basin criterion floods and then iteratively removes below-contrast basins, so the interesting comparisons are the removal-heavy large-segment cases. Parameters ---------- n_sources : int, optional The total number of Gaussian sources for the many-source scene. size : int, optional The image size for the large-segment scene. n_peaks : int, optional The number of compact peaks in the large segment. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ _, data, segm = make_inputs(n_sources, seed=seed) blend_data, blend_segm = make_blended_inputs(size, n_peaks, seed=seed) print('\n== deblend_sources: contrast_method ==') print(f'{"benchmark":>40}{"basin":>18}{"saddle":>18}') cases = [ (f'{segm.n_labels} small segments, c=0.001', data, segm, 0.001), (f'{n_peaks}-peak segment, c=0.001', blend_data, blend_segm, 0.001), (f'{n_peaks}-peak segment, c=0.01', blend_data, blend_segm, 0.01), (f'{n_peaks}-peak segment, c=0.03', blend_data, blend_segm, 0.03), ] for name, case_data, case_segm, contrast in cases: cells = [] for method in ('basin', 'saddle'): bench = partial(deblend_sources, case_data, case_segm, N_PIXELS, contrast=contrast, contrast_method=method) result = bench() t_best = time_best(bench, repeats=repeats) cells.append(f'{t_best:.4f}s ({result.n_labels})') row = ''.join(f'{cell:>18}' for cell in cells) print(f'{name:>40}{row}') def run_sep_extract(data, threshold, *, kernel=None, deblend=True): """ Run sep.extract with the SEP deblending defaults and return the segmentation map. Parameters ---------- data : 2D `~numpy.ndarray` The unfiltered image. threshold : float The detection threshold, applied to the filtered image when ``kernel`` is given. kernel : 2D `~numpy.ndarray` or `None`, optional The convolution kernel. If `None`, the image is not filtered. deblend : bool, optional Whether to deblend. SEP runs its multithreshold level loop whenever ``deblend_nthresh`` is larger than 1, regardless of ``deblend_cont``, so deblending is disabled by using a single level. Returns ------- segmap : 2D `~numpy.ndarray` The SEP segmentation map. """ n_levels = SEP_N_LEVELS if deblend else 1 _, segmap = sep.extract(data, threshold, minarea=N_PIXELS, filter_kernel=kernel, filter_type='conv', deblend_nthresh=n_levels, deblend_cont=SEP_CONTRAST, clean=False, segmentation_map=True) return segmap def time_and_count(func, *, repeats=3): """ Time a detection or deblending callable and count its labels. Parameters ---------- func : callable The zero-argument callable, returning either a `~photutils.segmentation.SegmentationImage` or a SEP segmentation map array. repeats : int, optional The number of repeats for the timing (best time is kept). Returns ------- t_best : float The best wall-clock time in seconds. n_labels : int The number of labels in the result. """ result = func() n_labels = getattr(result, 'n_labels', None) if n_labels is None: n_labels = int(result.max()) return time_best(func, repeats=repeats), n_labels def bench_sep_scene(name, data, detect_data, segm, threshold, *, kernel=None, repeats=3): """ Benchmark photutils detection and deblending against sep.extract on one scene. Each row pairs a photutils measurement with its SEP counterpart. The SEP deblend-only time is the difference between its runs with and without deblending, and the photutils detect-plus-deblend time is the sum of its two measured steps. The photutils cell shows the SEP / photutils time ratio in parentheses, and the n_labels column lists the photutils and SEP label counts. Parameters ---------- name : str The scene description printed in the section header. data : 2D `~numpy.ndarray` The unfiltered image, given to SEP. detect_data : 2D `~numpy.ndarray` The image detected and deblended by photutils. This is the image convolved with ``kernel``, or ``data`` itself when ``kernel`` is `None`. segm : `~photutils.segmentation.SegmentationImage` The photutils segmentation image of ``detect_data``. threshold : float The detection threshold. kernel : 2D `~numpy.ndarray` or `None`, optional The convolution kernel given to SEP. repeats : int, optional The number of repeats for each timing (best time is kept). """ # SEP needs room for every segment pixel on its pixel stack sep.set_extract_pixstack(max(sep.get_extract_pixstack(), data.size)) sep_segmap = run_sep_extract(data, threshold, kernel=kernel, deblend=False) if not np.array_equal(sep_segmap > 0, segm.data > 0): msg = 'SEP and photutils detected different segments' raise ValueError(msg) deblend = partial(deblend_sources, detect_data, segm, N_PIXELS, n_levels=SEP_N_LEVELS, contrast=SEP_CONTRAST, mode='exponential') sep_extract = partial(run_sep_extract, data, threshold, kernel=kernel) t_detect, n_detect = time_and_count( partial(detect_sources, detect_data, threshold, N_PIXELS), repeats=repeats) t_basin, n_basin = time_and_count( partial(deblend, contrast_method='basin'), repeats=repeats) t_saddle, n_saddle = time_and_count( partial(deblend, contrast_method='saddle'), repeats=repeats) t_sep_detect, n_sep_detect = time_and_count( partial(sep_extract, deblend=False), repeats=repeats) t_sep_total, n_sep_deblend = time_and_count( partial(sep_extract, deblend=True), repeats=repeats) # SEP's deblending cost is the extra time over its detect-only run t_sep_deblend = t_sep_total - t_sep_detect rows = [ ('detect', t_detect, t_sep_detect, n_detect, n_sep_detect), ('deblend (basin)', t_basin, t_sep_deblend, n_basin, n_sep_deblend), ('deblend (saddle)', t_saddle, t_sep_deblend, n_saddle, n_sep_deblend), ('detect + deblend (basin)', t_detect + t_basin, t_sep_total, n_basin, n_sep_deblend), ('detect + deblend (saddle)', t_detect + t_saddle, t_sep_total, n_saddle, n_sep_deblend), ] print(f'\n-- {name} --') print(f'{"benchmark":>26}{"photutils":>18}{"SEP":>12}' f'{"n_labels":>14}') for row_name, t_phot, t_sep, n_phot, n_sep in rows: ratio = f' ({t_sep / t_phot:.1f}x)' if t_sep > 0 else '' print(f'{row_name:>26}{f"{t_phot:.4f}s{ratio}":>18}' f'{f"{t_sep:.4f}s":>12}{f"{n_phot}/{n_sep}":>14}') def bench_sep(*, n_sources_sweep=(500, 1000, 2000, 4000), size_sweep=(250, 500, 1000, 2000), n_peaks=8, repeats=3, seed=0): """ Benchmark photutils detection and deblending against sep.extract. Both packages use the SEP deblending defaults (32 exponentially spaced levels and a contrast of 0.005), and SEP cleaning is disabled because photutils has no equivalent. For the many-source scenes, SEP is given the detection kernel with plain convolution and photutils deblends the convolved image, which is the image SEP deblends. The large-segment scenes use no kernel. Each scene checks that SEP detects the same segments as photutils before timing. Parameters ---------- n_sources_sweep : tuple of int, optional The numbers of Gaussian sources for the many-source scenes. size_sweep : tuple of int, optional The image sizes for the large-segment scenes. n_peaks : int, optional The number of compact peaks in each large segment. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== deblend_sources versus sep.extract ({SEP_N_LEVELS} ' f'levels, mode=exponential, contrast={SEP_CONTRAST}) ==') print('(Nx) = SEP / photutils time, n_labels = photutils/SEP') kernel = make_2dgaussian_kernel(3.0, size=5) # as in make_inputs kernel = np.asarray(kernel.array, dtype=np.float32) for n_sources in n_sources_sweep: data, convolved_data, segm = make_inputs(n_sources, seed=seed) name = (f'{n_sources} sources, {segm.n_labels} segments, ' f'{data.shape[0]}x{data.shape[1]} image') bench_sep_scene(name, data, convolved_data, segm, THRESHOLD, kernel=kernel, repeats=repeats) for size in size_sweep: data, segm = make_blended_inputs(size, n_peaks, amp_range=(50.0, 100.0), seed=seed) name = (f'{size}x{size} image, {n_peaks} peaks, segment area ' f'{int(segm.areas[0])}') bench_sep_scene(name, data, data, segm, BLEND_THRESHOLD, repeats=repeats) def main(): """ Run the source deblending benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for source deblending.') parser.add_argument('--n-sources', type=parse_int_list, default=[500, 1000, 2000, 4000], help='comma-separated source counts for the ' 'many-source benchmark ' '(default: 500,1000,2000,4000)') parser.add_argument('--sizes', type=parse_int_list, default=[250, 500, 1000, 2000], help='comma-separated image sizes for the ' 'large-source benchmark ' '(default: 250,500,1000,2000)') parser.add_argument('--n-peaks', type=parse_int_list, default=[10, 25, 50, 100], help='comma-separated peak counts for the ' 'many-peak benchmark ' '(default: 10,25,50,100)') parser.add_argument('--n-threads', type=parse_thread_counts, default=[1, 2, 4, 8], help='comma-separated n_threads values for the ' 'thread-scaling benchmark ' '(default: 1,2,4,8)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'many', 'large', 'peaks', 'stages', 'threads', 'criterion', 'profile', 'sep'], help='which benchmark to run (default: ' '%(default)s). The profile and sep ' 'benchmarks run only when selected.') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) warnings.filterwarnings('ignore', category=DeblendWarning) print_environment() if args.which == 'sep': if not HAS_SEP: print(f'sep is not available ({SEP_IMPORT_ERROR}). Nothing ' 'to compare') sys.exit(1) print(f'sep {sep.__version__}') bench_sep(n_sources_sweep=args.n_sources, size_sweep=args.sizes, repeats=args.repeats, seed=args.seed) return if args.which in ('all', 'many'): bench_many_sources(n_sources_sweep=args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'large'): bench_large_source(size_sweep=args.sizes, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'peaks'): bench_many_peaks(n_peaks_sweep=args.n_peaks, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'stages'): bench_stages(repeats=args.repeats, seed=args.seed) bench_stages(contrast=0.03, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'threads'): bench_threads(thread_counts=args.n_threads, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'criterion'): bench_contrast_method(repeats=args.repeats, seed=args.seed) if args.which == 'profile': bench_profile(seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_detection.py000077500000000000000000000327231526452531300234260ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.detection subpackage. The benchmarks cover the star finder classes (DAOStarFinder, IRAFStarFinder, and StarFinder) for both source detection and catalog-only (xycoords) runs, the find_peaks detection modes, the fast circular (min_separation) peak detection versus the equivalent circular-footprint maximum filter, and concurrent find_stars calls on a shared finder instance across thread counts. Run ``python benchmarks/bench_detection.py --help`` to see the available options. """ import argparse import warnings from concurrent.futures import ThreadPoolExecutor from functools import partial import numpy as np from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma from bench_helpers import (add_memory_argument, format_sweep_cells, parse_thread_counts, print_environment, set_memory_mode, time_best) from photutils.centroids import centroid_com from photutils.detection import (DAOStarFinder, IRAFStarFinder, StarFinder, find_peaks) from photutils.utils.exceptions import NoDetectionsWarning FWHM = 4.0 THRESHOLD = 10.0 def make_star_image(n_sources, *, spacing=25, fwhm=FWHM, amplitude=100.0, noise_std=1.0, seed=0): """ Return an image with a grid of Gaussian stars and their positions. Parameters ---------- n_sources : int The number of stars. The image is a square grid of ``spacing``-sized cells with one star per cell, so the image size is ``ceil(sqrt(n_sources)) * spacing`` per side. spacing : int, optional The grid cell size in pixels. fwhm : float, optional The FWHM of the Gaussian stars in pixels. amplitude : float, optional The amplitude of the Gaussian stars. noise_std : float, optional The standard deviation of the Gaussian noise. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. xypos : Nx2 `~numpy.ndarray` The (x, y) star positions. """ n_grid = int(np.ceil(np.sqrt(n_sources))) size = n_grid * spacing rng = np.random.default_rng(seed) data = rng.normal(0.0, noise_std, (size, size)) sigma = fwhm * gaussian_fwhm_to_sigma yy, xx = np.mgrid[0:spacing, 0:spacing] xypos = np.empty((n_sources, 2)) for i in range(n_sources): gy, gx = divmod(i, n_grid) xc = (spacing - 1) / 2.0 + rng.uniform(-0.5, 0.5) yc = (spacing - 1) / 2.0 + rng.uniform(-0.5, 0.5) model = Gaussian2D(amplitude, xc, yc, sigma, sigma) x0 = gx * spacing y0 = gy * spacing data[y0:y0 + spacing, x0:x0 + spacing] += model(xx, yy) xypos[i] = (x0 + xc, y0 + yc) return data, xypos def make_gaussian_kernel(size, *, fwhm=FWHM): """ Return a 2D Gaussian kernel array for StarFinder. Parameters ---------- size : int The kernel size; the kernel is ``(size, size)``. fwhm : float, optional The FWHM of the Gaussian kernel in pixels. Returns ------- result : 2D `~numpy.ndarray` The kernel array. """ cen = (size - 1) / 2.0 sigma = fwhm * gaussian_fwhm_to_sigma yy, xx = np.mgrid[0:size, 0:size] model = Gaussian2D(1.0, cen, cen, sigma, sigma) return model(xx, yy) def make_finders(xypos=None): """ Return the star finder name and instance pairs. Parameters ---------- xypos : Nx2 `~numpy.ndarray` or `None`, optional If not `None`, the DAOStarFinder and IRAFStarFinder instances are configured with these source positions (skipping the source-finding step). Returns ------- result : list of (str, finder) tuples The finder name and instance pairs. """ kwargs = {} if xypos is not None: kwargs['xycoords'] = np.round(xypos).astype(int) finders = [ ('DAOStarFinder', DAOStarFinder(THRESHOLD, FWHM, **kwargs)), ('IRAFStarFinder', IRAFStarFinder(THRESHOLD, FWHM, **kwargs)), ] if xypos is None: # StarFinder does not support xycoords kernel = make_gaussian_kernel(11) finders.append(('StarFinder', StarFinder(THRESHOLD, kernel))) return finders def bench_star_finders(*, n_sources=1000, repeats=3, seed=0): """ Benchmark the star finder classes. Each finder is timed for a full detection run and, for the DAOFIND-style finders, a catalog-only run with the source positions given via ``xycoords`` (skipping the source-finding step). Parameters ---------- n_sources : int, optional The number of stars in the image. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, xypos = make_star_image(n_sources, seed=seed) print(f'\n== Star finders ({n_sources} stars, ' f'{data.shape[0]}x{data.shape[1]} image) ==') print(f'{"finder":>16}{"detect":>12}{"catalog-only":>14}{"n_found":>9}') catalog_times = {name: 'n/a' for name, _ in make_finders()} for name, finder in make_finders(xypos=xypos): t_best = time_best(partial(finder, data), repeats=repeats) catalog_times[name] = f'{t_best:.4f}s' for name, finder in make_finders(): tbl = finder(data) t_best = time_best(partial(finder, data), repeats=repeats) print(f'{name:>16}{f"{t_best:.4f}s":>12}' f'{catalog_times[name]:>14}{len(tbl):>9}') def bench_find_peaks(*, n_sources=1000, repeats=3, seed=0): """ Benchmark the find_peaks detection modes. Parameters ---------- n_sources : int, optional The number of stars in the image. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, _ = make_star_image(n_sources, seed=seed) print(f'\n== find_peaks ({n_sources} stars, ' f'{data.shape[0]}x{data.shape[1]} image) ==') print(f'{"mode":>34}{"time":>12}{"n_peaks":>9}') modes = [ ('box_size=3', {'box_size': 3}), ('box_size=11', {'box_size': 11}), ('footprint=ones((11, 11))', {'footprint': np.ones((11, 11))}), ('min_separation=10', {'min_separation': 10}), ('box_size=3 + centroid_com', {'box_size': 3, 'centroid_func': centroid_com}), ] for name, kwargs in modes: tbl = find_peaks(data, THRESHOLD, **kwargs) bench = partial(find_peaks, data, THRESHOLD, **kwargs) t_best = time_best(bench, repeats=repeats) print(f'{name:>34}{f"{t_best:.4f}s":>12}{len(tbl):>9}') def bench_min_separation(*, n_sources=1000, radii=(5, 10, 25, 50), repeats=3, seed=0): """ Benchmark the fast circular (min_separation) peak detection against the equivalent circular-footprint maximum filter. Parameters ---------- n_sources : int, optional The number of stars in the image. radii : tuple of int, optional The circular-region radii in pixels. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, _ = make_star_image(n_sources, seed=seed) print(f'\n== find_peaks min_separation vs circular footprint ' f'({n_sources} stars, {data.shape[0]}x{data.shape[1]} ' 'image) ==') print(f'{"radius":>8}{"footprint":>12}{"min_separation":>16}' f'{"speedup":>9}') for radius in radii: idx = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(idx, idx) footprint = np.array((xx**2 + yy**2) <= radius**2, dtype=int) with warnings.catch_warnings(): warnings.simplefilter('ignore', NoDetectionsWarning) bench_ref = partial(find_peaks, data, THRESHOLD, footprint=footprint) t_ref = time_best(bench_ref, repeats=repeats) bench_fast = partial(find_peaks, data, THRESHOLD, min_separation=radius) t_fast = time_best(bench_fast, repeats=repeats) print(f'{radius:>8}{f"{t_ref:.4f}s":>12}{f"{t_fast:.4f}s":>16}' f'{f"{t_ref / t_fast:.1f}x":>9}') def run_finder_concurrent(finder, data, n_calls, n_threads): """ Run concurrent find_stars calls on a shared finder instance. Parameters ---------- finder : `~photutils.detection.StarFinderBase` The shared star finder instance. data : 2D `~numpy.ndarray` The image. n_calls : int The total number of calls. n_threads : int The number of worker threads. """ with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(finder, data) for _ in range(n_calls)] for future in futures: future.result() def bench_finder_threads(*, n_sources=1000, n_calls=8, thread_counts=(1, 2, 4, 8), repeats=3, seed=0): """ Benchmark concurrent find_stars calls on a shared DAOStarFinder. Parameters ---------- n_sources : int, optional The number of stars in the image. n_calls : int, optional The total number of find_stars calls per timing. thread_counts : tuple of int, optional The numbers of worker threads. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, _ = make_star_image(n_sources, seed=seed) finder = DAOStarFinder(THRESHOLD, FWHM) finder(data) # warm up times = [] for n_threads in thread_counts: bench = partial(run_finder_concurrent, finder, data, n_calls, n_threads) times.append(time_best(bench, repeats=repeats)) print(f'\n== DAOStarFinder thread scaling ({n_sources} stars, ' f'{data.shape[0]}x{data.shape[1]} image, {n_calls} concurrent ' 'calls on a shared instance) ==') header = ''.join(f'{f"{n} thr":>18}' for n in thread_counts) print(header) print(''.join(f'{cell:>18}' for cell in format_sweep_cells(times))) def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'5,10,25'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def main(): """ Run the photutils.detection benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.detection subpackage.') parser.add_argument('--n-sources', type=int, default=1000, help='number of stars in the image ' '(default: %(default)s)') parser.add_argument('--radii', type=parse_int_list, default=[5, 10, 25, 50], help='comma-separated radii for the ' 'min-separation benchmark ' '(default: 5,10,25,50)') parser.add_argument('--n-calls', type=int, default=8, help='number of concurrent find_stars calls for ' 'the thread-scaling benchmark ' '(default: %(default)s)') parser.add_argument('--n-threads', type=parse_thread_counts, default=[1, 2, 4, 8], help='comma-separated thread counts for the ' 'thread-scaling benchmark (default: 1,2,4,8)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'finders', 'find-peaks', 'min-separation', 'threads'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'finders'): bench_star_finders(n_sources=args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'find-peaks'): bench_find_peaks(n_sources=args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'min-separation'): bench_min_separation(n_sources=args.n_sources, radii=args.radii, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'threads'): bench_finder_threads(n_sources=args.n_sources, n_calls=args.n_calls, thread_counts=args.n_threads, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_epsf_builder.py000077500000000000000000000336431526452531300241150ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for ePSF building with EPSFBuilder. The benchmarks cover the wall-clock time of a full build and its number of iterations versus the number of stars and versus the oversampling factor, and the cost of the stages of a single build iteration (residual stacking, sigma clipping and median, smoothing and recentering, and star fitting). Run ``python benchmarks/bench_epsf_builder.py --help`` to see the available options. """ import argparse import time import warnings from functools import partial import numpy as np from astropy.nddata import NDData from astropy.table import Table from bench_helpers import (add_memory_argument, parse_int_list, print_environment, set_memory_mode, time_best) from photutils.datasets import make_model_image from photutils.psf import CircularGaussianPRF, EPSFBuilder, extract_stars def make_stars(n_stars, *, fwhm=2.8, cutout_size=25, seed=0): """ Return extracted star cutouts from a noisy scene of Gaussian stars. The stars are placed on a regular grid with random subpixel offsets so that exactly ``n_stars`` well-separated stars are generated. They have fluxes spanning a factor of ten and Poisson-like noise plus a constant background noise. Parameters ---------- n_stars : int The number of stars. fwhm : float, optional The FWHM of the stars in pixels. cutout_size : int, optional The size of the star cutouts in pixels. seed : int, optional The random number generator seed. Returns ------- result : `~photutils.psf.EPSFStars` The extracted stars. """ rng = np.random.default_rng(seed) separation = cutout_size + 10 n_side = int(np.ceil(np.sqrt(n_stars))) size = (n_side + 1) * separation # Grid cell centers, leaving a border of one cell around the image grid = (np.arange(n_side) + 1) * separation yy, xx = np.meshgrid(grid, grid, indexing='ij') xpos = xx.ravel()[:n_stars] + rng.uniform(-0.5, 0.5, n_stars) ypos = yy.ravel()[:n_stars] + rng.uniform(-0.5, 0.5, n_stars) flux = rng.uniform(1e4, 1e5, n_stars) params = Table({'x_0': xpos, 'y_0': ypos, 'flux': flux}) model = CircularGaussianPRF(fwhm=fwhm) data = make_model_image((size, size), model, params, model_shape=(cutout_size + 4, cutout_size + 4)) data += rng.normal(0.0, np.sqrt(np.abs(data) + 100.0)) catalog = Table({'x': xpos, 'y': ypos}) return extract_stars(NDData(data), catalog, size=cutout_size) def make_builder(oversampling, *, maxiters=10, **kwargs): """ Return an `~photutils.psf.EPSFBuilder` with the benchmark settings. Parameters ---------- oversampling : int The oversampling factor. maxiters : int, optional The maximum number of build iterations. **kwargs : dict, optional Additional keyword arguments passed to `EPSFBuilder`. Returns ------- result : `~photutils.psf.EPSFBuilder` The builder. """ return EPSFBuilder(oversampling=oversampling, maxiters=maxiters, progress_bar=False, **kwargs) def build(builder, stars): """ Build an ePSF, suppressing the builder warnings. Parameters ---------- builder : `~photutils.psf.EPSFBuilder` The builder. stars : `~photutils.psf.EPSFStars` The stars. Returns ------- result : `~photutils.psf.EPSFBuildResults` The build results. """ with warnings.catch_warnings(): warnings.simplefilter('ignore') return builder(stars) def bench_stars(*, n_stars_list=(100, 400, 1600), oversampling=2, fwhm=2.8, cutout_size=25, maxiters=10, repeats=3, seed=0): """ Benchmark a full build versus the number of stars. Parameters ---------- n_stars_list : tuple of int, optional The numbers of stars. oversampling : int, optional The oversampling factor. fwhm : float, optional The FWHM of the stars in pixels. cutout_size : int, optional The size of the star cutouts in pixels. maxiters : int, optional The maximum number of build iterations. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== EPSFBuilder versus the number of stars ' f'(oversampling={oversampling}, fwhm={fwhm}, ' f'cutout_size={cutout_size}, maxiters={maxiters}) ==') print(f'{"n_stars":>10}{"iterations":>12}{"total":>12}' f'{"per iter":>12}{"per star-iter":>16}') for n_stars in n_stars_list: stars = make_stars(n_stars, fwhm=fwhm, cutout_size=cutout_size, seed=seed) builder = make_builder(oversampling, maxiters=maxiters) result = build(builder, stars) t_build = time_best(partial(build, builder, stars), repeats=repeats) per_iter = t_build / result.iterations print(f'{n_stars:>10}{result.iterations:>12}' f'{f"{t_build:.2f}s":>12}{f"{per_iter:.3f}s":>12}' f'{f"{per_iter / n_stars * 1e3:.3f}ms":>16}') def bench_oversampling(*, oversampling_list=(1, 2, 4), n_stars=400, fwhm=2.8, cutout_size=25, maxiters=10, repeats=3, seed=0): """ Benchmark a full build versus the oversampling factor. Parameters ---------- oversampling_list : tuple of int, optional The oversampling factors. n_stars : int, optional The number of stars. fwhm : float, optional The FWHM of the stars in pixels. cutout_size : int, optional The size of the star cutouts in pixels. maxiters : int, optional The maximum number of build iterations. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ stars = make_stars(n_stars, fwhm=fwhm, cutout_size=cutout_size, seed=seed) print(f'\n== EPSFBuilder versus the oversampling factor ' f'(n_stars={n_stars}, fwhm={fwhm}, cutout_size={cutout_size}, ' f'maxiters={maxiters}) ==') print(f'{"oversampling":>14}{"ePSF shape":>14}{"iterations":>12}' f'{"total":>12}{"per iter":>12}') for oversampling in oversampling_list: builder = make_builder(oversampling, maxiters=maxiters) result = build(builder, stars) t_build = time_best(partial(build, builder, stars), repeats=repeats) shape = 'x'.join(str(s) for s in result.epsf.data.shape) print(f'{oversampling:>14}{shape:>14}{result.iterations:>12}' f'{f"{t_build:.2f}s":>12}' f'{f"{t_build / result.iterations:.3f}s":>12}') def bench_stages(*, n_stars=400, oversampling=2, fwhm=2.8, cutout_size=25, repeats=3, seed=0): """ Benchmark the stages of a single build iteration. The stages are timed on the ePSF and star centers of a converged build, so that the star fits start close to their solutions as they do in the later build iterations. Parameters ---------- n_stars : int, optional The number of stars. oversampling : int, optional The oversampling factor. fwhm : float, optional The FWHM of the stars in pixels. cutout_size : int, optional The size of the star cutouts in pixels. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ from photutils.psf.epsf_builder import _suppress_alias_modes from photutils.psf.image_models import ImagePSF from photutils.utils._stats import nanmedian stars = make_stars(n_stars, fwhm=fwhm, cutout_size=cutout_size, seed=seed) builder = make_builder(oversampling, maxiters=10) result = build(builder, stars) epsf = result.epsf stars = result.fitted_stars def stack(): """ Stack the resampled star residuals. """ return builder._resample_residuals(stars, epsf) residuals = stack() def clip_and_median(): """ Sigma clip the residual stack and take its median. """ with warnings.catch_warnings(): warnings.simplefilter('ignore') clipped = builder._sigma_clip(residuals, axis=0, masked=False, return_bounds=False) return nanmedian(clipped, axis=0) median = clip_and_median() new_epsf = epsf.data + median def smooth_and_recenter(): """ Smooth, low-pass filter, recenter, and normalize the ePSF. """ builder._update_auto_parameters(new_epsf) smoothed = builder._smooth_epsf(new_epsf) smoothed = _suppress_alias_modes(smoothed, builder.oversampling) temp = ImagePSF(data=smoothed, origin=epsf.origin, oversampling=builder.oversampling, fill_value=None) return builder._normalize_epsf(builder._recenter_epsf(temp)) def fit(): """ Fit the ePSF to the stars. """ with warnings.catch_warnings(): warnings.simplefilter('ignore') return builder._fit_stars(epsf, stars) def iteration(): """ Run a full build iteration. """ with warnings.catch_warnings(): warnings.simplefilter('ignore') return builder._process_iteration(stars, epsf, 2) timings = [('residual stacking', time_best(stack, repeats=repeats)), ('sigma clip and median', time_best(clip_and_median, repeats=repeats)), ('smooth and recenter', time_best(smooth_and_recenter, repeats=repeats)), ('star fitting', time_best(fit, repeats=repeats)), ('full iteration', time_best(iteration, repeats=repeats))] kernel_shape = (None if result.smoothing_kernel is None else result.smoothing_kernel.shape) print(f'\n== Build iteration stages (n_stars={n_stars}, ' f'oversampling={oversampling}, fwhm={fwhm}, ' f'cutout_size={cutout_size}, fit_shape={result.fit_shape}, ' f'kernel={kernel_shape}) ==') print(f'{"stage":>26}{"time":>12}{"fraction":>12}') total = timings[-1][1] for name, t_stage in timings: print(f'{name:>26}{f"{t_stage:.3f}s":>12}' f'{f"{t_stage / total * 100:.0f}%":>12}') def main(): """ Run the ePSF building benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for ePSF building with EPSFBuilder.') parser.add_argument('--n-stars-list', type=parse_int_list, default=[100, 400, 1600], help='comma-separated numbers of stars for the ' 'stars benchmark (default: 100,400,1600)') parser.add_argument('--n-stars', type=int, default=400, help='number of stars for the oversampling and ' 'stages benchmarks (default: %(default)s)') parser.add_argument('--oversampling-list', type=parse_int_list, default=[1, 2, 4], help='comma-separated oversampling factors for ' 'the oversampling benchmark (default: 1,2,4)') parser.add_argument('--oversampling', type=int, default=2, help='oversampling factor for the stars and ' 'stages benchmarks (default: %(default)s)') parser.add_argument('--fwhm', type=float, default=2.8, help='FWHM of the stars in pixels ' '(default: %(default)s)') parser.add_argument('--cutout-size', type=int, default=25, help='size of the star cutouts in pixels ' '(default: %(default)s)') parser.add_argument('--maxiters', type=int, default=10, help='maximum number of build iterations ' '(default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'stars', 'oversampling', 'stages'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() t0 = time.perf_counter() if args.which in ('all', 'stars'): bench_stars(n_stars_list=args.n_stars_list, oversampling=args.oversampling, fwhm=args.fwhm, cutout_size=args.cutout_size, maxiters=args.maxiters, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'oversampling'): bench_oversampling(oversampling_list=args.oversampling_list, n_stars=args.n_stars, fwhm=args.fwhm, cutout_size=args.cutout_size, maxiters=args.maxiters, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'stages'): bench_stages(n_stars=args.n_stars, oversampling=args.oversampling, fwhm=args.fwhm, cutout_size=args.cutout_size, repeats=args.repeats, seed=args.seed) print(f'\ntotal benchmark time: {time.perf_counter() - t0:.1f}s') if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_geometry.py000077500000000000000000000267361526452531300233120ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.geometry subpackage. The benchmarks cover the per-call cost of the overlap grid functions (circular_overlap_grid, elliptical_overlap_grid, rectangular_overlap_grid, and polygon_overlap_grid) for each overlap method (exact, center, and subpixel), the scaling with grid size, and the scaling of the polygon overlap with the number of vertices for convex and nonconvex polygons. Run ``python benchmarks/bench_geometry.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.geometry import (circular_overlap_grid, elliptical_overlap_grid, rectangular_overlap_grid) from photutils.geometry._polygon_overlap import polygon_overlap_grid # The theta used for all rotated shapes THETA = 0.5 def make_polygon_vertices(n_vertices, radius, *, star=False): """ Return the vertex coordinates of a regular or star-shaped polygon centered on the origin. Parameters ---------- n_vertices : int The number of polygon vertices. radius : float The circumradius of the polygon. star : bool, optional If `True`, alternate vertices are placed at half the radius, producing a nonconvex (star-shaped) polygon. ``n_vertices`` must be even in that case. Returns ------- vertices_x, vertices_y : 1D `~numpy.ndarray` The x and y vertex coordinates. """ angles = np.linspace(0.0, 2.0 * np.pi, n_vertices, endpoint=False) radii = np.full(n_vertices, radius) if star: radii[1::2] = 0.5 * radius vertices_x = np.ascontiguousarray(radii * np.cos(angles)) vertices_y = np.ascontiguousarray(radii * np.sin(angles)) return vertices_x, vertices_y def make_grid_functions(grid_size, *, fill_frac=0.4, n_vertices=6): """ Return the overlap grid functions with their shape arguments bound, leaving only ``use_exact`` and ``subpixels`` free. The grid spans ``grid_size`` unit pixels per side and the shapes are centered on the origin with a size of ``fill_frac`` times the grid half-extent, so the boundary band, the wholly-inside region, and the wholly-outside region are all exercised. Parameters ---------- grid_size : int The number of grid pixels per side. fill_frac : float, optional The shape size as a fraction of the grid half-extent. n_vertices : int, optional The number of vertices of the (convex) benchmark polygon. Returns ------- result : list of (str, callable) tuples The shape name and callable pairs; each callable takes ``(use_exact, subpixels)``. """ extent = grid_size / 2.0 grid_args = (-extent, extent, -extent, extent, grid_size, grid_size) radius = fill_frac * extent vertices_x, vertices_y = make_polygon_vertices(n_vertices, radius) return [ ('circle', partial(circular_overlap_grid, *grid_args, radius)), ('ellipse', partial(elliptical_overlap_grid, *grid_args, radius, 0.6 * radius, THETA)), ('rectangle', partial(rectangular_overlap_grid, *grid_args, 1.6 * radius, 1.0 * radius, THETA)), (f'polygon ({n_vertices} vert)', partial(polygon_overlap_grid, *grid_args, vertices_x, vertices_y)), ] def time_grid(func, use_exact, subpixels, *, n_iter, repeats): """ Return the best per-call time of an overlap grid callable. Parameters ---------- func : callable The overlap grid callable taking ``(use_exact, subpixels)``. use_exact, subpixels : int The overlap method arguments. n_iter : int The number of calls per timing; the per-call time is returned. repeats : int The number of repeats for each timing (best time is kept). Returns ------- result : float The best per-call wall-clock time in seconds. """ def run(): """ Call the overlap grid callable ``n_iter`` times. """ for _ in range(n_iter): func(use_exact, subpixels) return time_best(run, repeats=repeats) / n_iter def bench_shapes(*, grid_size=200, subpixels=5, n_iter=10, repeats=3): """ Benchmark each overlap grid function for each overlap method. Parameters ---------- grid_size : int, optional The number of grid pixels per side. subpixels : int, optional The number of subpixels (per dimension) for the subpixel method. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ methods = [('exact', 1, 1), ('center', 0, 1), (f'subpixel({subpixels})', 0, subpixels)] print(f'\n== Overlap grid functions ({grid_size}x{grid_size} grid, ' f'per-call time) ==') header = f'{"shape":>18}' header += ''.join(f'{name:>14}' for name, _, _ in methods) print(header) for name, func in make_grid_functions(grid_size): cells = '' for _, use_exact, subpix in methods: t_call = time_grid(func, use_exact, subpix, n_iter=n_iter, repeats=repeats) cells += f'{t_call * 1e3:>12.3f}ms' print(f'{name:>18}{cells}') def bench_grid_sizes(*, sizes=(50, 100, 200, 400, 800), n_iter=3, repeats=3): """ Benchmark the exact-mode overlap grid functions across grid sizes. The shape size scales with the grid so the boundary-band fraction stays constant. Parameters ---------- sizes : tuple of int, optional The grid sizes (pixels per side). n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ print('\n== Exact overlap vs grid size (per-call time) ==') names = [name for name, _ in make_grid_functions(sizes[0])] header = f'{"grid":>10}' header += ''.join(f'{name:>18}' for name in names) print(header) for size in sizes: cells = '' for _, func in make_grid_functions(size): t_call = time_grid(func, 1, 1, n_iter=n_iter, repeats=repeats) cells += f'{t_call * 1e3:>16.3f}ms' print(f'{f"{size}x{size}":>10}{cells}') def bench_polygon_vertices(*, n_vertices=(4, 8, 32, 128, 1024), grid_size=200, subpixels=5, n_iter=3, repeats=3): """ Benchmark the polygon overlap grid across vertex counts for convex and nonconvex (star-shaped) polygons. Convex polygons use the interior/exterior fast path, so only the boundary band pays the per-vertex clipping cost; nonconvex polygons clip every pixel in the bounding box. Parameters ---------- n_vertices : tuple of int, optional The vertex counts. Each must be even (required for the star shapes). grid_size : int, optional The number of grid pixels per side. subpixels : int, optional The number of subpixels (per dimension) for the subpixel method. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ extent = grid_size / 2.0 grid_args = (-extent, extent, -extent, extent, grid_size, grid_size) radius = 0.4 * extent columns = [('exact convex', False, 1, 1), ('exact star', True, 1, 1), (f'subpix({subpixels}) convex', False, 0, subpixels), (f'subpix({subpixels}) star', True, 0, subpixels)] print(f'\n== polygon_overlap_grid vs vertex count ' f'({grid_size}x{grid_size} grid, per-call time) ==') header = f'{"vertices":>10}' header += ''.join(f'{name:>18}' for name, _, _, _ in columns) print(header) for n_verts in n_vertices: cells = '' for _, star, use_exact, subpix in columns: vertices_x, vertices_y = make_polygon_vertices( n_verts, radius, star=star) func = partial(polygon_overlap_grid, *grid_args, vertices_x, vertices_y) t_call = time_grid(func, use_exact, subpix, n_iter=n_iter, repeats=repeats) cells += f'{t_call * 1e3:>16.3f}ms' print(f'{n_verts:>10}{cells}') def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'50,100,200'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def main(): """ Run the photutils.geometry benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.geometry subpackage.') parser.add_argument('--grid-size', type=int, default=200, help='grid pixels per side for the shape and ' 'polygon benchmarks (default: %(default)s)') parser.add_argument('--sizes', type=parse_int_list, default=[50, 100, 200, 400, 800], help='comma-separated grid sizes for the ' 'grid-size scaling benchmark ' '(default: 50,100,200,400,800)') parser.add_argument('--n-vertices', type=parse_int_list, default=[4, 8, 32, 128, 1024], help='comma-separated (even) vertex counts for ' 'the polygon benchmark ' '(default: 4,8,32,128,1024)') parser.add_argument('--subpixels', type=int, default=5, help='subpixels per dimension for the subpixel ' 'method (default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'shapes', 'sizes', 'polygon'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) if any(n % 2 for n in args.n_vertices): parser.error('--n-vertices values must be even') print_environment() if args.which in ('all', 'shapes'): bench_shapes(grid_size=args.grid_size, subpixels=args.subpixels, repeats=args.repeats) if args.which in ('all', 'sizes'): bench_grid_sizes(sizes=args.sizes, repeats=args.repeats) if args.which in ('all', 'polygon'): bench_polygon_vertices(n_vertices=args.n_vertices, grid_size=args.grid_size, subpixels=args.subpixels, repeats=args.repeats) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_helpers.py000066400000000000000000000176641526452531300231160ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Shared helper functions for the photutils benchmark scripts. """ import gc import os import platform import sys import time import tracemalloc import numpy as np from photutils.aperture import (CircularAnnulus, CircularAperture, EllipticalAnnulus, EllipticalAperture, PolygonAperture, RectangularAnnulus, RectangularAperture) from photutils.datasets import make_wcs # Whether time_best also reports the memory of each call (see # set_memory_mode) _options = {'memory': False} def add_memory_argument(parser): """ Add the ``--memory`` option to a benchmark argument parser. Pass the parsed value to `set_memory_mode`. Parameters ---------- parser : `argparse.ArgumentParser` The argument parser of the benchmark script. """ parser.add_argument('--memory', action='store_true', help='also report the peak and retained memory ' 'of each timed call, measured with ' 'tracemalloc in one untimed call before ' 'the timed repeats (the memory line is ' 'printed before the timing row)') def set_memory_mode(enabled): """ Enable or disable the memory report of `time_best`. Parameters ---------- enabled : bool Whether `time_best` reports the peak and retained memory of each call. """ _options['memory'] = bool(enabled) def print_memory(func): """ Call ``func`` once under `tracemalloc` and print its peak and retained memory. The peak is the largest traced memory above the level at the start of the call. The retained memory is what remains allocated after the call returns and unreachable objects are collected, such as values cached on an object that outlives the call. Only allocations made through the Python allocator are traced, which includes NumPy arrays but not memory that extension code obtains from ``malloc`` directly. Parameters ---------- func : callable The zero-argument callable to measure. """ gc.collect() tracemalloc.start() try: start = tracemalloc.get_traced_memory()[0] func() peak = tracemalloc.get_traced_memory()[1] gc.collect() current = tracemalloc.get_traced_memory()[0] finally: tracemalloc.stop() print(f' memory: peak {(peak - start) / 1e6:.1f} MB, ' f'retained {(current - start) / 1e6:.1f} MB') def time_best(func, *, repeats=3): """ Return the best wall-clock time of ``repeats`` calls to ``func``. When the memory mode is enabled (see `set_memory_mode`), ``func`` is first called once more under `tracemalloc` and its peak and retained memory are printed (see `print_memory`). The timed repeats run without tracing, so the times are unaffected. Parameters ---------- func : callable The zero-argument callable to time. repeats : int, optional The number of times to call ``func``. Returns ------- result : float The best (minimum) wall-clock time in seconds. """ if _options['memory']: print_memory(func) best = np.inf for _ in range(repeats): t0 = time.perf_counter() func() best = min(best, time.perf_counter() - t0) return best def make_image(shape, *, seed=0): """ Return a Gaussian-noise image of the given shape. Parameters ---------- shape : tuple of int The shape of the output image. seed : int, optional The random number generator seed. Returns ------- result : 2D `~numpy.ndarray` The noise image. """ rng = np.random.default_rng(seed) return rng.normal(100.0, 5.0, shape) def print_environment(): """ Print information about the runtime environment. """ import astropy import scipy import photutils from photutils.utils._optional_deps import HAS_BOTTLENECK gil = getattr(sys, '_is_gil_enabled', lambda: True)() print(f'python {platform.python_version()} (GIL enabled: {gil}), ' f'{os.cpu_count()} CPUs') print(f'photutils {photutils.__version__}, numpy {np.__version__}, ' f'scipy {scipy.__version__}, astropy {astropy.__version__}, ' f'bottleneck: {HAS_BOTTLENECK}') print(f'photutils path: {os.path.dirname(photutils.__file__)}') def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'250,500,1000'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def parse_thread_counts(text): """ Parse a comma-separated list of thread counts. Parameters ---------- text : str The comma-separated thread counts (e.g., ``'1,2,4,8'``). Returns ------- result : list of int The thread counts. """ return parse_int_list(text) def format_sweep_cells(times): """ Format thread-sweep times, with speedups relative to the first time. Parameters ---------- times : list of float The wall-clock times in seconds, one per thread count. Returns ------- result : list of str The formatted cells (e.g., ``['0.3270s', '0.1700s (1.9x)']``). """ cells = [f'{times[0]:.4f}s'] cells.extend(f'{t:.4f}s ({times[0] / t:.1f}x)' for t in times[1:]) return cells def make_apertures(positions): """ Return all of the pixel-based aperture types at the given positions, with roughly comparable sizes. Parameters ---------- positions : 2D `~numpy.ndarray` The (x, y) aperture positions. Returns ------- result : list of (str, aperture) tuples The aperture name and instance pairs. """ theta = 0.5 return [ ('CircularAperture', CircularAperture(positions, r=8.0)), ('CircularAnnulus', CircularAnnulus(positions, r_in=5.0, r_out=10.0)), ('EllipticalAperture', EllipticalAperture(positions, a=10.0, b=6.0, theta=theta)), ('EllipticalAnnulus', EllipticalAnnulus(positions, a_in=6.0, a_out=12.0, b_out=8.0, theta=theta)), ('RectangularAperture', RectangularAperture(positions, w=14.0, h=10.0, theta=theta)), ('RectangularAnnulus', RectangularAnnulus(positions, w_in=8.0, w_out=16.0, h_out=12.0, theta=theta)), ('PolygonAperture', PolygonAperture.from_regular_polygon(positions, n_vertices=6, radius=8.0)), ] def make_aperture_inputs(size, n_sources, *, seed=0): """ Return the data, error, wcs, and aperture positions used by the aperture benchmark scripts. Parameters ---------- size : int The image size; the image is ``(size, size)``. n_sources : int The number of aperture positions. seed : int, optional The random number generator seed. Returns ------- data, error : 2D `~numpy.ndarray` The image and its error array. wcs : `~astropy.wcs.WCS` A WCS for the image. positions : 2D `~numpy.ndarray` The (x, y) aperture positions. """ data = make_image((size, size), seed=seed) error = np.sqrt(np.abs(data)) wcs = make_wcs(data.shape) rng = np.random.default_rng(seed) margin = 20.0 positions = np.column_stack( [rng.uniform(margin, size - margin, n_sources), rng.uniform(margin, size - margin, n_sources)]) return data, error, wcs, positions astropy-photutils-24be529/benchmarks/bench_isophote.py000077500000000000000000000263371526452531300233060ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.isophote subpackage. The benchmarks cover full isophote fitting with Ellipse.fit_image, elliptical sample extraction with EllipseSample.extract for each integration mode, model-image reconstruction with build_ellipse_model, and the scaling of the private build_ellipse_model_c Cython kernel with image size. Run ``python benchmarks/bench_isophote.py --help`` to see the available options. """ import argparse import warnings from functools import partial import numpy as np from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.isophote import (Ellipse, EllipseGeometry, EllipseSample, build_ellipse_model) from photutils.isophote._ellipse_model import build_ellipse_model_c from photutils.isophote.tests.make_test_data import make_test_image FIT_MODES = ('bilinear', 'nearest_neighbor', 'mean', 'median') EXTRACT_MODES = ('bilinear', 'nearest_neighbor', 'mean', 'median') # Geometry of the reference isophote in the simulated galaxy image X0 = Y0 = 256.0 SMA0 = 20.0 EPS = 0.2 PA = 0.0 def make_galaxy_image(*, seed=0): """ Return the simulated galaxy image used by the benchmarks. Parameters ---------- seed : int, optional The random number generator seed. Returns ------- result : 2D `~numpy.ndarray` The simulated galaxy image. """ return make_test_image(seed=seed) def run_fit_image(data, *, integrmode='bilinear', maxsma=200.0): """ Run a full isophote fit on the data. RuntimeWarnings from degenerate inner and outer isophotes (e.g., zero gradients) are routine during fitting and are suppressed. Parameters ---------- data : 2D `~numpy.ndarray` The galaxy image. integrmode : str, optional The integration mode. maxsma : float, optional The maximum semimajor axis length. Returns ------- result : `~photutils.isophote.IsophoteList` The fitted isophotes. """ geometry = EllipseGeometry(X0, Y0, SMA0, EPS, PA) ellipse = Ellipse(data, geometry=geometry) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return ellipse.fit_image(integrmode=integrmode, maxsma=maxsma) def bench_fit_image(*, maxsma=200.0, seed=0): """ Benchmark Ellipse.fit_image for each integration mode. Each fit is timed once (a full fit takes seconds and its runtime is stable). Parameters ---------- maxsma : float, optional The maximum semimajor axis length. seed : int, optional The random number generator seed. """ data = make_galaxy_image(seed=seed) print(f'\n== Ellipse.fit_image ({data.shape[1]}x{data.shape[0]} ' f'image, maxsma={maxsma:g}) ==') print(f'{"mode":>18}{"time":>12}{"isophotes":>12}') for mode in FIT_MODES: bench = partial(run_fit_image, data, integrmode=mode, maxsma=maxsma) try: t_run = time_best(bench, repeats=1) isolist = bench() except ValueError as exc: print(f'{mode:>18} failed ({exc})') continue print(f'{mode:>18}{f"{t_run:.3f}s":>12}{len(isolist):>12}') def run_extract(data, sma, n_iter, *, integrmode='bilinear'): """ Extract an elliptical sample repeatedly from fresh samples. A fresh EllipseSample is created for every extraction because extract caches its result on the instance. Parameters ---------- data : 2D `~numpy.ndarray` The galaxy image. sma : float The semimajor axis length of the sample. n_iter : int The number of extractions. integrmode : str, optional The integration mode. """ for _ in range(n_iter): sample = EllipseSample(data, sma, x0=X0, y0=Y0, eps=EPS, position_angle=PA, integrmode=integrmode) sample.extract() def bench_sample(*, sma_list=(20.0, 80.0), n_iter=20, repeats=3, seed=0): """ Benchmark EllipseSample.extract for each integration mode. Parameters ---------- sma_list : tuple of float, optional The semimajor axis lengths to sample at. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data = make_galaxy_image(seed=seed) print('\n== EllipseSample.extract (per-call time) ==') header = f'{"mode":>18}' for sma in sma_list: header += f'{f"sma={sma:g}":>12}' print(header) for mode in EXTRACT_MODES: cells = '' for sma in sma_list: bench = partial(run_extract, data, sma, n_iter, integrmode=mode) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.3f}ms":>12}' print(f'{mode:>18}{cells}') def bench_model(*, maxsma=200.0, repeats=3, seed=0): """ Benchmark build_ellipse_model, with and without high harmonics. The isophote fit that provides the input IsophoteList is run once and is not included in the timings. Parameters ---------- maxsma : float, optional The maximum semimajor axis length of the isophote fit. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data = make_galaxy_image(seed=seed) isolist = run_fit_image(data, maxsma=maxsma) print(f'\n== build_ellipse_model ({data.shape[1]}x{data.shape[0]} ' f'image, {len(isolist)} isophotes) ==') print(f'{"variant":>18}{"time":>12}') for name, high_harmonics in (('no harmonics', False), ('high harmonics', True)): bench = partial(build_ellipse_model, data.shape, isolist, high_harmonics=high_harmonics) t_run = time_best(bench, repeats=repeats) print(f'{name:>18}{f"{t_run * 1e3:.1f}ms":>12}') def run_kernel(size, *, harmonics=False): """ Call the build_ellipse_model_c kernel for a size x size image. The isophote parameter arrays mimic the finely spaced (0.01 pixel) interpolated arrays that build_ellipse_model passes to the kernel, with the outermost isophote at 0.35 * size. Parameters ---------- size : int The image size; the image is ``(size, size)``. harmonics : bool, optional Whether to include the harmonic arrays. """ max_sma = 0.35 * size n_sma = int(max_sma / 0.01) sma = np.linspace(0.5, max_sma, n_sma) intens = np.full(n_sma, 100.0) eps = np.full(n_sma, EPS) pa = np.full(n_sma, 0.4) x0 = np.full(n_sma, size / 2.0) y0 = np.full(n_sma, size / 2.0) harmonic_arrays = [] if harmonics: harmonic_arrays = [np.full(n_sma, 0.01) for _ in range(4)] build_ellipse_model_c(size, size, sma, intens, eps, pa, x0, y0, *harmonic_arrays) def bench_kernel(*, sizes=(256, 512), repeats=3): """ Benchmark the build_ellipse_model_c kernel across image sizes. Parameters ---------- sizes : tuple of int, optional The image sizes (pixels per side). repeats : int, optional The number of repeats for each timing (best time is kept). """ print('\n== build_ellipse_model_c kernel vs image size ==') print(f'{"size":>12}{"no harmonics":>14}{"harmonics":>14}') for size in sizes: cells = '' for harmonics in (False, True): bench = partial(run_kernel, size, harmonics=harmonics) t_run = time_best(bench, repeats=repeats) cells += f'{f"{t_run * 1e3:.1f}ms":>14}' print(f'{f"{size}x{size}":>12}{cells}') def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'256,512'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def parse_float_list(text): """ Parse a comma-separated list of positive floats. Parameters ---------- text : str The comma-separated floats (e.g., ``'20,80'``). Returns ------- result : list of float The parsed floats. """ values = [float(item) for item in text.split(',')] if any(value <= 0 for value in values): msg = 'values must be positive' raise ValueError(msg) return values def main(): """ Run the photutils.isophote benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.isophote subpackage.') parser.add_argument('--maxsma', type=float, default=200.0, help='maximum semimajor axis length for the ' 'isophote fits (default: %(default)s)') parser.add_argument('--sma-list', type=parse_float_list, default=[20.0, 80.0], help='comma-separated semimajor axis lengths ' 'for the sample benchmark (default: ' '20,80)') parser.add_argument('--sizes', type=parse_int_list, default=[256, 512], help='comma-separated image sizes for the ' 'kernel benchmark (default: 256,512)') parser.add_argument('--n-iter', type=int, default=20, help='number of calls per timing for the ' 'sample benchmark (default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'fit-image', 'sample', 'model', 'kernel'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'fit-image'): bench_fit_image(maxsma=args.maxsma, seed=args.seed) if args.which in ('all', 'sample'): bench_sample(sma_list=args.sma_list, n_iter=args.n_iter, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'model'): bench_model(maxsma=args.maxsma, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'kernel'): bench_kernel(sizes=args.sizes, repeats=args.repeats) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_morphology.py000077500000000000000000000206221526452531300236420ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.morphology subpackage. The benchmarks cover the per-call cost of data_properties (catalog construction alone and with the morphological properties computed, with and without a mask, background, and WCS) and the scaling of gini with array size, with and without a mask. Run ``python benchmarks/bench_morphology.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.datasets import make_wcs from photutils.morphology import data_properties, gini # The morphological properties accessed by the data_properties # benchmark (computing them is lazy, so construction is timed # separately from property access) PROPERTY_NAMES = ['x_centroid', 'y_centroid', 'semimajor_axis', 'semiminor_axis', 'orientation', 'eccentricity', 'area'] BACKGROUND_PROPERTY_NAMES = ['background_mean', 'background_sum'] def make_source_cutout(size, *, amplitude=100.0, noise_std=1.0, seed=0): """ Return a cutout image containing a single Gaussian source. The source FWHM scales with the cutout size so that the source fills a constant fraction of the cutout. Parameters ---------- size : int The cutout size; the cutout is ``(size, size)``. amplitude : float, optional The amplitude of the Gaussian source. noise_std : float, optional The standard deviation of the Gaussian noise. seed : int, optional The random number generator seed. Returns ------- result : 2D `~numpy.ndarray` The cutout image. """ rng = np.random.default_rng(seed) yy, xx = np.mgrid[0:size, 0:size] xc = yc = (size - 1) / 2.0 sigma = (size / 8.0) * gaussian_fwhm_to_sigma model = Gaussian2D(amplitude, xc, yc, sigma, sigma) return model(xx, yy) + rng.normal(0.0, noise_std, (size, size)) def run_data_properties(data, n_iter, property_names, **kwargs): """ Call data_properties repeatedly and access the given properties. Parameters ---------- data : 2D `~numpy.ndarray` The cutout image. n_iter : int The number of calls. property_names : list of str The names of the properties to access on each returned catalog. **kwargs : dict, optional Keyword arguments passed to ``data_properties``. """ for _ in range(n_iter): props = data_properties(data, **kwargs) for name in property_names: getattr(props, name) def bench_data_properties(*, cutout_size=51, n_iter=10, repeats=3, seed=0): """ Benchmark the per-call cost of data_properties. Catalog construction alone is timed separately from construction plus computing the morphological properties (the properties are computed lazily), and the mask, background, and WCS variants are timed with their relevant properties. Parameters ---------- cutout_size : int, optional The cutout size; the cutout is ``(cutout_size, cutout_size)``. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data = make_source_cutout(cutout_size, seed=seed) mask = data < 5.0 # isolate source pixels background = 1.0 wcs = make_wcs(data.shape) variants = [ ('construct only', {}, []), ('+ properties', {}, PROPERTY_NAMES), ('+ mask', {'mask': mask}, PROPERTY_NAMES), ('+ background', {'background': background}, PROPERTY_NAMES + BACKGROUND_PROPERTY_NAMES), ('+ wcs (sky_centroid)', {'wcs': wcs}, ['sky_centroid']), ] print(f'\n== data_properties ({cutout_size}x{cutout_size} cutout, ' f'per-call time) ==') print(f'{"variant":>22}{"time":>12}') for name, kwargs, property_names in variants: bench = partial(run_data_properties, data, n_iter, property_names, **kwargs) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{name:>22}{f"{t_call * 1e3:.3f}ms":>12}') def run_gini(data, n_iter, **kwargs): """ Call gini repeatedly on the data. Parameters ---------- data : 2D `~numpy.ndarray` The cutout image. n_iter : int The number of calls. **kwargs : dict, optional Keyword arguments passed to ``gini``. """ for _ in range(n_iter): gini(data, **kwargs) def bench_gini(*, sizes=(64, 256, 1024), n_iter=10, repeats=3, seed=0): """ Benchmark gini across array sizes, with and without a mask. Parameters ---------- sizes : tuple of int, optional The array sizes (pixels per side). n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print('\n== gini vs array size (per-call time) ==') print(f'{"size":>12}{"no mask":>12}{"with mask":>12}') rng = np.random.default_rng(seed) for size in sizes: data = make_source_cutout(size, seed=seed) # A sparse mask keeps the number of unmasked values comparable # to the no-mask case, so the columns show the masking overhead mask = rng.random(data.shape) < 0.01 cells = '' for kwargs in ({}, {'mask': mask}): bench = partial(run_gini, data, n_iter, **kwargs) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.3f}ms":>12}' print(f'{f"{size}x{size}":>12}{cells}') def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'64,256,1024'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def main(): """ Run the photutils.morphology benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.morphology ' 'subpackage.') parser.add_argument('--cutout-size', type=int, default=51, help='data_properties cutout size ' '(default: %(default)s)') parser.add_argument('--sizes', type=parse_int_list, default=[64, 256, 1024], help='comma-separated array sizes for the gini ' 'benchmark (default: 64,256,1024)') parser.add_argument('--n-iter', type=int, default=10, help='number of calls per timing ' '(default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'data-properties', 'gini'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'data-properties'): bench_data_properties(cutout_size=args.cutout_size, n_iter=args.n_iter, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'gini'): bench_gini(sizes=args.sizes, n_iter=args.n_iter, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_profiles.py000077500000000000000000000310501526452531300232630ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.profiles subpackage. The benchmarks cover the per-call cost of constructing each profile class and computing its profile for each aperture overlap method, the scaling of the profile computation with the number of radial bins, and the cost of the RadialProfile extras (the raw data profile and the Gaussian and Moffat model fits). Run ``python benchmarks/bench_profiles.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.profiles import (CurveOfGrowth, EllipticalCurveOfGrowth, EnsquaredCurveOfGrowth, RadialProfile) # The overlap methods used by the profile-classes benchmark METHODS = ('exact', 'center', 'subpixel') def make_source_image(size, *, amplitude=100.0, noise_std=1.0, seed=0): """ Return an image with a single Gaussian source and an error array. The source FWHM scales with the image size so that the source fills a constant fraction of the image. Parameters ---------- size : int The image size; the image is ``(size, size)``. amplitude : float, optional The amplitude of the Gaussian source. noise_std : float, optional The standard deviation of the Gaussian noise. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. error : 2D `~numpy.ndarray` The error array. xycen : tuple of float The ``(x, y)`` source center. """ rng = np.random.default_rng(seed) yy, xx = np.mgrid[0:size, 0:size] xc = yc = (size - 1) / 2.0 sigma = (size / 8.0) * gaussian_fwhm_to_sigma model = Gaussian2D(amplitude, xc, yc, sigma, sigma) data = model(xx, yy) + rng.normal(0.0, noise_std, (size, size)) error = np.full((size, size), noise_std) return data, error, (xc, yc) def make_profile(profile_class, data, error, xycen, n_radii, max_radius, **kwargs): """ Construct a profile instance covering the given radial range. Parameters ---------- profile_class : class The profile class to construct. data : 2D `~numpy.ndarray` The image. error : 2D `~numpy.ndarray` The error array. xycen : tuple of float The ``(x, y)`` source center. n_radii : int The number of radial bins. max_radius : float The maximum radius (or half-size) in pixels. **kwargs : dict, optional Additional keyword arguments passed to the profile class (e.g., ``method`` or ``axis_ratio``). Returns ------- result : `~photutils.profiles.ProfileBase` The profile instance. """ if profile_class is RadialProfile: radii = np.linspace(0.0, max_radius, n_radii + 1) else: radii = np.linspace(1.0, max_radius, n_radii) if profile_class is EllipticalCurveOfGrowth: kwargs.setdefault('axis_ratio', 0.5) kwargs.setdefault('theta', 0.5) return profile_class(data, xycen, radii, error=error, **kwargs) def run_profile(profile_class, data, error, xycen, n_radii, max_radius, n_iter, **kwargs): """ Construct a profile and compute its outputs repeatedly. Each call constructs a new instance and accesses the ``profile``, ``profile_error``, and ``area`` attributes (the computation is lazy and cached per instance). Parameters ---------- profile_class : class The profile class to construct. data : 2D `~numpy.ndarray` The image. error : 2D `~numpy.ndarray` The error array. xycen : tuple of float The ``(x, y)`` source center. n_radii : int The number of radial bins. max_radius : float The maximum radius (or half-size) in pixels. n_iter : int The number of calls. **kwargs : dict, optional Additional keyword arguments passed to the profile class. """ for _ in range(n_iter): profile = make_profile(profile_class, data, error, xycen, n_radii, max_radius, **kwargs) for name in ('profile', 'profile_error', 'area'): getattr(profile, name) def bench_classes(*, size=501, n_radii=100, n_iter=5, repeats=3, seed=0): """ Benchmark each profile class for each overlap method. Each timing includes profile construction and the computation of the ``profile``, ``profile_error``, and ``area`` attributes. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_radii : int, optional The number of radial bins. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, error, xycen = make_source_image(size, seed=seed) max_radius = size / 4.0 classes = (RadialProfile, CurveOfGrowth, EnsquaredCurveOfGrowth, EllipticalCurveOfGrowth) print(f'\n== profile classes ({size}x{size} image, {n_radii} radii, ' 'per-call time) ==') header = f'{"class":>24}' for method in METHODS: header += f'{method:>12}' print(header) for profile_class in classes: cells = '' for method in METHODS: bench = partial(run_profile, profile_class, data, error, xycen, n_radii, max_radius, n_iter, method=method) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.2f}ms":>12}' print(f'{profile_class.__name__:>24}{cells}') def bench_radii_scaling(*, size=501, n_radii_list=(25, 100, 400), n_iter=5, repeats=3, seed=0): """ Benchmark the profile computation versus the number of radii. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_radii_list : tuple of int, optional The numbers of radial bins. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, error, xycen = make_source_image(size, seed=seed) max_radius = size / 4.0 classes = (RadialProfile, CurveOfGrowth) print(f'\n== scaling with the number of radii ({size}x{size} image, ' 'per-call time) ==') header = f'{"n_radii":>12}' for profile_class in classes: header += f'{profile_class.__name__:>16}' print(header) for n_radii in n_radii_list: cells = '' for profile_class in classes: bench = partial(run_profile, profile_class, data, error, xycen, n_radii, max_radius, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.2f}ms":>16}' print(f'{n_radii:>12}{cells}') def run_radial_extra(data, error, xycen, n_radii, max_radius, n_iter, attributes): """ Construct a RadialProfile and access the given attributes. Parameters ---------- data : 2D `~numpy.ndarray` The image. error : 2D `~numpy.ndarray` The error array. xycen : tuple of float The ``(x, y)`` source center. n_radii : int The number of radial bins. max_radius : float The maximum radius in pixels. n_iter : int The number of calls. attributes : list of str The names of the attributes to access on each instance. """ for _ in range(n_iter): profile = make_profile(RadialProfile, data, error, xycen, n_radii, max_radius) for name in ['profile', *attributes]: getattr(profile, name) def bench_radial_extras(*, size=501, n_radii=100, n_iter=5, repeats=3, seed=0): """ Benchmark the extra lazily-computed RadialProfile attributes. The baseline row times construction plus the ``profile`` computation alone; the remaining rows add one extra attribute each. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_radii : int, optional The number of radial bins. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, error, xycen = make_source_image(size, seed=seed) max_radius = size / 4.0 variants = [ ('profile only', []), ('+ data_profile', ['data_profile']), ('+ gaussian_fit', ['gaussian_fit']), ('+ moffat_fit', ['moffat_fit']), ] print(f'\n== RadialProfile extras ({size}x{size} image, {n_radii} ' 'radii, per-call time) ==') print(f'{"variant":>18}{"time":>12}') for name, attributes in variants: bench = partial(run_radial_extra, data, error, xycen, n_radii, max_radius, n_iter, attributes) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{name:>18}{f"{t_call * 1e3:.2f}ms":>12}') def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'25,100,400'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def main(): """ Run the photutils.profiles benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.profiles subpackage.') parser.add_argument('--size', type=int, default=501, help='image size (default: %(default)s)') parser.add_argument('--n-radii', type=int, default=100, help='number of radial bins ' '(default: %(default)s)') parser.add_argument('--n-radii-list', type=parse_int_list, default=[25, 100, 400], help='comma-separated numbers of radial bins ' 'for the scaling benchmark ' '(default: 25,100,400)') parser.add_argument('--n-iter', type=int, default=5, help='number of calls per timing ' '(default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'classes', 'radii-scaling', 'radial-extras'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'classes'): bench_classes(size=args.size, n_radii=args.n_radii, n_iter=args.n_iter, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'radii-scaling'): bench_radii_scaling(size=args.size, n_radii_list=args.n_radii_list, n_iter=args.n_iter, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'radial-extras'): bench_radial_extras(size=args.size, n_radii=args.n_radii, n_iter=args.n_iter, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_psf.py000077500000000000000000000674671526452531300222550ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.psf subpackage. The benchmarks cover the per-call evaluation cost of the PSF models (the functional models, ImagePSF, and GriddedPSFModel), PSFPhotometry versus the number of sources and versus the PSF model type, IterativePSFPhotometry, the ImagePSF and GriddedPSFModel analytic Jacobians versus the finite-difference approximation, SourceGrouper scaling, ePSF building (extract_stars and EPSFBuilder), the Gaussian fitting helpers (fit_2dgaussian and fit_fwhm), and make_psf_model_image. Run ``python benchmarks/bench_psf.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from astropy.nddata import NDData from astropy.stats import gaussian_fwhm_to_sigma from astropy.table import Table from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.detection import DAOStarFinder from photutils.psf import (AiryDiskPSF, CircularGaussianPRF, CircularGaussianPSF, CircularGaussianSigmaPRF, EPSFBuilder, GaussianPRF, GaussianPSF, GriddedPSFModel, ImagePSF, IterativePSFPhotometry, MoffatPSF, PSFPhotometry, SourceGrouper, extract_stars, fit_2dgaussian, fit_fwhm, make_psf_model_image) # The FWHM (pixels) used for all of the PSF models FWHM = 2.8 # The shape of the model cutouts used to render the scene images MODEL_SHAPE = (25, 25) def make_epsf_data(*, fwhm=FWHM, oversampling=4, half_size=12): """ Return an oversampled circular-Gaussian ePSF image. Parameters ---------- fwhm : float, optional The FWHM of the Gaussian in detector pixels. oversampling : int, optional The oversampling factor of the output image. half_size : int, optional The half size of the ePSF in detector pixels; the output image has ``2 * half_size * oversampling + 1`` pixels along each axis. Returns ------- result : 2D `~numpy.ndarray` The oversampled ePSF image, normalized to sum to ``oversampling**2``. """ size = 2 * half_size * oversampling + 1 yy, xx = np.mgrid[0:size, 0:size] / oversampling cen = (size - 1) / 2.0 / oversampling model = CircularGaussianPSF(flux=1.0, x_0=cen, y_0=cen, fwhm=fwhm) data = model(xx, yy) return data / data.sum() * oversampling**2 def make_image_psf(*, fwhm=FWHM, oversampling=4): """ Return an `~photutils.psf.ImagePSF` model of a circular Gaussian. Parameters ---------- fwhm : float, optional The FWHM of the Gaussian in detector pixels. oversampling : int, optional The oversampling factor of the ePSF image. Returns ------- result : `~photutils.psf.ImagePSF` The image PSF model. """ data = make_epsf_data(fwhm=fwhm, oversampling=oversampling) return ImagePSF(data, oversampling=oversampling) def make_gridded_psf_model(*, fwhm=FWHM, oversampling=4, n_psfs_side=3, image_size=2048, model_class=GriddedPSFModel): """ Return a synthetic `~photutils.psf.GriddedPSFModel`. All of the grid planes hold the same circular-Gaussian ePSF image, on an ``n_psfs_side`` x ``n_psfs_side`` grid of fiducial positions spanning the image. Parameters ---------- fwhm : float, optional The FWHM of the Gaussian in detector pixels. oversampling : int, optional The oversampling factor of the ePSF images. n_psfs_side : int, optional The number of fiducial positions along each axis. image_size : int, optional The size of the detector image spanned by the grid. model_class : class, optional The gridded PSF model class to construct. Returns ------- result : `~photutils.psf.GriddedPSFModel` The gridded PSF model. """ epsf = make_epsf_data(fwhm=fwhm, oversampling=oversampling) coords = np.linspace(0, image_size - 1, n_psfs_side) grid_xypos = [(x, y) for y in coords for x in coords] planes = np.repeat(epsf[None], len(grid_xypos), axis=0) meta = {'grid_xypos': grid_xypos, 'oversampling': oversampling} return model_class(NDData(planes, meta=meta)) class _NoDerivImagePSF(ImagePSF): """ An ImagePSF variant with the analytic Jacobian disabled. It is used by the Jacobian benchmark to time the fitter's finite-difference fallback. """ fit_deriv = None class _NoDerivGriddedPSFModel(GriddedPSFModel): """ A GriddedPSFModel variant with the analytic Jacobian disabled. It is used by the Jacobian benchmark to time the fitter's finite-difference fallback. """ fit_deriv = None def make_psf_models(): """ Return the PSF models used by the model benchmarks. All of the models have roughly the same FWHM. Returns ------- result : list of (str, model) tuples The model name and instance pairs. """ sigma = FWHM * gaussian_fwhm_to_sigma return [ ('CircularGaussianPRF', CircularGaussianPRF(fwhm=FWHM)), ('CircularGaussianPSF', CircularGaussianPSF(fwhm=FWHM)), ('CircularGaussianSigmaPRF', CircularGaussianSigmaPRF(sigma=sigma)), ('GaussianPRF', GaussianPRF(x_fwhm=FWHM, y_fwhm=FWHM)), ('GaussianPSF', GaussianPSF(x_fwhm=FWHM, y_fwhm=FWHM)), ('MoffatPSF', MoffatPSF(alpha=FWHM, beta=2.5)), ('AiryDiskPSF', AiryDiskPSF(radius=FWHM)), ('ImagePSF', make_image_psf()), ('GriddedPSFModel', make_gridded_psf_model()), ] def make_scene(psf_model, shape, n_sources, *, flux_range=(100, 1000), min_separation=10, noise_std=0.1, seed=0): """ Return a noisy image of PSF sources and their true parameters. Parameters ---------- psf_model : 2D `astropy.modeling.Model` The PSF model used to render the sources. shape : tuple of int The shape of the output image. n_sources : int The number of sources. flux_range : tuple of float, optional The lower and upper bounds of the source fluxes. min_separation : float, optional The minimum separation between sources in pixels. noise_std : float, optional The standard deviation of the Gaussian noise. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. params : `~astropy.table.Table` The true source parameters (``x_0``, ``y_0``, and ``flux``). """ data, params = make_psf_model_image(shape, psf_model, n_sources, model_shape=MODEL_SHAPE, min_separation=min_separation, border_size=15, seed=seed, flux=flux_range) rng = np.random.default_rng(seed) data = data + rng.normal(0.0, noise_std, shape) return data, params def run_model_eval(model, xx, yy, n_iter): """ Evaluate a PSF model on the given grid repeatedly. Parameters ---------- model : 2D `astropy.modeling.Model` The PSF model to evaluate. xx, yy : 2D `~numpy.ndarray` The x and y coordinate grids. n_iter : int The number of calls. """ for _ in range(n_iter): model(xx, yy) def bench_model_eval(*, sizes=(25, 361), n_iter=20, repeats=3): """ Benchmark the per-call evaluation cost of each PSF model. Each model is evaluated on square coordinate grids with the model centered on the grid. Parameters ---------- sizes : tuple of int, optional The grid sizes; each grid is ``(size, size)``. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ models = make_psf_models() print('\n== PSF model evaluation (per-call time) ==') header = f'{"model":>26}' for size in sizes: header += f'{f"{size}x{size}":>12}' print(header) for name, model in models: cells = '' for size in sizes: yy, xx = np.mgrid[0:size, 0:size].astype(float) cen = (size - 1) / 2.0 model.x_0 = cen model.y_0 = cen bench = partial(run_model_eval, model, xx, yy, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.3f}ms":>12}' print(f'{name:>26}{cells}') def run_photometry(phot, data, init_params, error=None): """ Run a PSF photometry instance once. Parameters ---------- phot : `~photutils.psf.PSFPhotometry` The photometry instance. data : 2D `~numpy.ndarray` The image. init_params : `~astropy.table.Table` The initial source parameters. error : 2D `~numpy.ndarray`, optional The error array. """ phot(data, error=error, init_params=init_params) def bench_photometry(*, size=2048, n_sources_list=(100, 400, 1600), repeats=3, seed=0): """ Benchmark PSFPhotometry versus the number of sources. The source positions and fluxes are given via ``init_params``, so the timings exclude source detection. The variants are a basic run, a run with an error array, and a run with a SourceGrouper. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources_list : tuple of int, optional The numbers of sources. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ psf_model = CircularGaussianPRF(fwhm=FWHM) grouper = SourceGrouper(min_separation=15) variants = [ ('basic', {}, None), ('+ error', {}, 'error'), ('+ grouper', {'grouper': grouper}, None), ] print(f'\n== PSFPhotometry ({size}x{size} image, init_params given, ' 'per-call time) ==') header = f'{"n_sources":>12}' for name, _, _ in variants: header += f'{name:>22}' print(header) for n_sources in n_sources_list: data, params = make_scene(psf_model, (size, size), n_sources, seed=seed) error = np.full(data.shape, 0.1) init_params = params['x_0', 'y_0', 'flux'] cells = '' for _, kwargs, use_error in variants: phot = PSFPhotometry(psf_model, fit_shape=(5, 5), group_warning_threshold=10**9, **kwargs) bench = partial(run_photometry, phot, data, init_params, error if use_error else None) t_call = time_best(bench, repeats=repeats) per_source = t_call / n_sources * 1e3 cells += f'{f"{t_call:.3f}s ({per_source:.2f}ms/src)":>22}' print(f'{n_sources:>12}{cells}') def bench_iterative(*, size=512, n_sources=100, repeats=3, seed=0): """ Benchmark finder-based PSFPhotometry and IterativePSFPhotometry. The baseline row is a single PSFPhotometry pass using a DAOStarFinder; the other rows run IterativePSFPhotometry in the ``new`` and ``all`` modes. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources : int, optional The number of sources. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ psf_model = CircularGaussianPRF(fwhm=FWHM) data, _ = make_scene(psf_model, (size, size), n_sources, seed=seed) finder = DAOStarFinder(threshold=1.0, fwhm=FWHM) grouper = SourceGrouper(min_separation=15) kwargs = {'grouper': grouper, 'aperture_radius': 4.0, 'group_warning_threshold': 10**9} variants = [ ('PSFPhotometry (finder)', PSFPhotometry(psf_model, (5, 5), finder=finder, **kwargs)), ("IterativePSFPhotometry (mode='new')", IterativePSFPhotometry(psf_model, (5, 5), finder, maxiters=2, mode='new', **kwargs)), ("IterativePSFPhotometry (mode='all')", IterativePSFPhotometry(psf_model, (5, 5), finder, maxiters=2, mode='all', **kwargs)), ] print(f'\n== iterative photometry ({size}x{size} image, {n_sources} ' 'sources, source detection included) ==') print(f'{"variant":>38}{"time":>12}') for name, phot in variants: bench = partial(phot, data) t_call = time_best(bench, repeats=repeats) print(f'{name:>38}{f"{t_call:.3f}s":>12}') def bench_model_types(*, size=512, n_sources=100, repeats=3, seed=0): """ Benchmark PSFPhotometry versus the PSF model type. For each PSF model, the scene is rendered with that model and then fit with the same model, with the source positions and fluxes given via ``init_params``. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources : int, optional The number of sources. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ print(f'\n== PSFPhotometry versus PSF model ({size}x{size} image, ' f'{n_sources} sources, per-call time) ==') print(f'{"model":>26}{"time":>22}') for name, psf_model in make_psf_models(): data, params = make_scene(psf_model, (size, size), n_sources, seed=seed) init_params = params['x_0', 'y_0', 'flux'] phot = PSFPhotometry(psf_model, fit_shape=(5, 5), group_warning_threshold=10**9) bench = partial(run_photometry, phot, data, init_params) t_call = time_best(bench, repeats=repeats) per_source = t_call / n_sources * 1e3 cell = f'{t_call:.3f}s ({per_source:.2f}ms/src)' print(f'{name:>26}{cell:>22}') def bench_jacobian(*, size=512, n_sources=100, fit_shapes=(5, 11), repeats=3, seed=0): """ Benchmark the analytic PSF model Jacobians in PSFPhotometry. For ImagePSF and GriddedPSFModel, the analytic Jacobian (``fit_deriv``) is compared against the fitter's finite-difference approximation, with the source positions and fluxes given via ``init_params``. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources : int, optional The number of sources. fit_shapes : tuple of int, optional The fit shapes; each fit shape is ``(fit_shape, fit_shape)``. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ epsf_data = make_epsf_data() families = [ ('ImagePSF', ImagePSF(epsf_data, oversampling=4), _NoDerivImagePSF(epsf_data, oversampling=4)), ('GriddedPSFModel', make_gridded_psf_model(image_size=size), make_gridded_psf_model(image_size=size, model_class=_NoDerivGriddedPSFModel)), ] print(f'\n== PSF model Jacobians ({size}x{size} image, {n_sources} ' 'sources, per-call time) ==') header = f'{"variant":>40}' for fit_shape in fit_shapes: header += f'{f"fit_shape={fit_shape}":>22}' print(header) for family, analytic_model, numeric_model in families: data, params = make_scene(analytic_model, (size, size), n_sources, seed=seed) init_params = params['x_0', 'y_0', 'flux'] times = {} variants = [('analytic (fit_deriv)', analytic_model), ('finite difference', numeric_model)] for name, psf_model in variants: cells = '' for fit_shape in fit_shapes: phot = PSFPhotometry(psf_model, fit_shape=fit_shape, group_warning_threshold=10**9) bench = partial(run_photometry, phot, data, init_params) t_call = time_best(bench, repeats=repeats) times[name, fit_shape] = t_call per_source = t_call / n_sources * 1e3 cells += f'{f"{t_call:.3f}s ({per_source:.2f}ms/src)":>22}' print(f'{f"{family} {name}":>40}{cells}') cells = '' for fit_shape in fit_shapes: speedup = (times['finite difference', fit_shape] / times['analytic (fit_deriv)', fit_shape]) cells += f'{f"{speedup:.2f}x":>22}' print(f'{f"{family} speedup":>40}{cells}') def run_grouper(grouper, x, y, n_iter): """ Run a SourceGrouper repeatedly. Parameters ---------- grouper : `~photutils.psf.SourceGrouper` The grouper instance. x, y : 1D `~numpy.ndarray` The source positions. n_iter : int The number of calls. """ for _ in range(n_iter): grouper(x, y) def bench_grouper(*, size=4096, n_sources_list=(4000, 16000, 64000), separations=(5, 25), n_iter=5, repeats=3, seed=0): """ Benchmark SourceGrouper versus the number of sources. Parameters ---------- size : int, optional The side length of the square field containing the positions. n_sources_list : tuple of int, optional The numbers of sources. separations : tuple of float, optional The ``min_separation`` values. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ rng = np.random.default_rng(seed) print(f'\n== SourceGrouper ({size}x{size} field, per-call time) ==') header = f'{"n_sources":>12}' for separation in separations: header += f'{f"min_sep={separation}":>16}' print(header) for n_sources in n_sources_list: x = rng.uniform(0, size, n_sources) y = rng.uniform(0, size, n_sources) cells = '' for separation in separations: grouper = SourceGrouper(min_separation=separation) bench = partial(run_grouper, grouper, x, y, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.2f}ms":>16}' print(f'{n_sources:>12}{cells}') def bench_epsf(*, size=2048, n_stars_list=(25, 100), oversampling=4, maxiters=5, repeats=3, seed=0): """ Benchmark ePSF building (extract_stars and EPSFBuilder). Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_stars_list : tuple of int, optional The numbers of stars. oversampling : int, optional The EPSFBuilder oversampling factor. maxiters : int, optional The maximum number of EPSFBuilder iterations. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ psf_model = CircularGaussianPRF(fwhm=FWHM) print(f'\n== ePSF building ({size}x{size} image, ' f'oversampling={oversampling}, maxiters={maxiters}) ==') print(f'{"n_stars":>12}{"extract_stars":>16}{"EPSFBuilder":>16}') for n_stars in n_stars_list: data, params = make_scene(psf_model, (size, size), n_stars, flux_range=(500, 1000), min_separation=25, noise_std=0.01, seed=seed) nddata = NDData(data) catalog = Table({'x': params['x_0'], 'y': params['y_0']}) bench = partial(extract_stars, nddata, catalog, size=25) t_extract = time_best(bench, repeats=repeats) stars = extract_stars(nddata, catalog, size=25) builder = EPSFBuilder(oversampling=oversampling, maxiters=maxiters, progress_bar=False) bench = partial(builder, stars) t_build = time_best(bench, repeats=repeats) print(f'{n_stars:>12}{f"{t_extract * 1e3:.2f}ms":>16}' f'{f"{t_build:.3f}s":>16}') def bench_fit_helpers(*, size=1024, n_sources=100, n_iter=5, repeats=3, seed=0): """ Benchmark the Gaussian fitting helpers. The helpers are timed on a single-source cutout image and at many positions (via ``xypos``) on a full image. Parameters ---------- size : int, optional The full-image size; the image is ``(size, size)``. n_sources : int, optional The number of sources in the full image. n_iter : int, optional The number of calls per timing (single-source case only); the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ rng = np.random.default_rng(seed) cutout_size = MODEL_SHAPE[0] cen = (cutout_size - 1) / 2.0 yy, xx = np.mgrid[0:cutout_size, 0:cutout_size] cutout_model = CircularGaussianPRF(flux=500.0, x_0=cen, y_0=cen, fwhm=FWHM) cutout = cutout_model(xx, yy) + rng.normal(0.0, 0.01, MODEL_SHAPE) psf_model = CircularGaussianPRF(fwhm=FWHM) data, params = make_scene(psf_model, (size, size), n_sources, flux_range=(500, 1000), min_separation=25, noise_std=0.01, seed=seed) xypos = np.column_stack([params['x_0'], params['y_0']]) variants = [ ('fit_fwhm', partial(fit_fwhm)), ('fit_2dgaussian (fix_fwhm=True)', partial(fit_2dgaussian, fix_fwhm=True)), ('fit_2dgaussian (fix_fwhm=False)', partial(fit_2dgaussian, fix_fwhm=False)), ] print(f'\n== Gaussian fitting helpers (per-call time; ' f'{n_sources} sources in the many-source column) ==') print(f'{"function":>34}{"1 source":>14}{f"{n_sources} sources":>14}') for name, func in variants: def run_single(func=func): for _ in range(n_iter): func(cutout, fit_shape=7) t_single = time_best(run_single, repeats=repeats) / n_iter bench = partial(func, data, xypos=xypos, fit_shape=7) t_many = time_best(bench, repeats=repeats) print(f'{name:>34}{f"{t_single * 1e3:.2f}ms":>14}' f'{f"{t_many:.3f}s":>14}') def bench_simulation(*, size=2048, n_sources_list=(100, 400, 1600), model_sizes=(15, 25, 45), repeats=3, seed=0): """ Benchmark make_psf_model_image versus the number of sources. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources_list : tuple of int, optional The numbers of sources. model_sizes : tuple of int, optional The model cutout sizes; each cutout is ``(msize, msize)``. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ psf_model = CircularGaussianPRF(fwhm=FWHM) print(f'\n== make_psf_model_image ({size}x{size} image, per-call ' 'time) ==') header = f'{"n_sources":>12}' for model_size in model_sizes: header += f'{f"shape={model_size}":>14}' print(header) for n_sources in n_sources_list: cells = '' for model_size in model_sizes: bench = partial(make_psf_model_image, (size, size), psf_model, n_sources, model_shape=(model_size, model_size), min_separation=10, seed=seed, flux=(100, 1000)) t_call = time_best(bench, repeats=repeats) cells += f'{f"{t_call:.3f}s":>14}' print(f'{n_sources:>12}{cells}') def parse_int_list(text): """ Parse a comma-separated list of positive integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'100,400,1600'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 for value in values): msg = 'values must be positive integers' raise ValueError(msg) return values def main(): """ Run the photutils.psf benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.psf subpackage.') parser.add_argument('--n-sources-list', type=parse_int_list, default=[100, 400, 1600], help='comma-separated numbers of sources for the ' 'photometry and simulation benchmarks ' '(default: 100,400,1600)') parser.add_argument('--n-grouper-list', type=parse_int_list, default=[4000, 16000, 64000], help='comma-separated numbers of sources for the ' 'grouper benchmark (default: 4000,16000,64000)') parser.add_argument('--n-stars-list', type=parse_int_list, default=[25, 100], help='comma-separated numbers of stars for the ' 'ePSF benchmark (default: 25,100)') parser.add_argument('--sizes', type=parse_int_list, default=[25, 361], help='comma-separated grid sizes for the ' 'model-evaluation benchmark (default: 25,361)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'model-eval', 'photometry', 'iterative', 'model-types', 'jacobian', 'grouper', 'epsf', 'fit-helpers', 'simulation'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'model-eval'): bench_model_eval(sizes=args.sizes, repeats=args.repeats) if args.which in ('all', 'photometry'): bench_photometry(n_sources_list=args.n_sources_list, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'iterative'): bench_iterative(repeats=args.repeats, seed=args.seed) if args.which in ('all', 'model-types'): bench_model_types(repeats=args.repeats, seed=args.seed) if args.which in ('all', 'jacobian'): bench_jacobian(repeats=args.repeats, seed=args.seed) if args.which in ('all', 'grouper'): bench_grouper(n_sources_list=args.n_grouper_list, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'epsf'): bench_epsf(n_stars_list=args.n_stars_list, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'fit-helpers'): bench_fit_helpers(repeats=args.repeats, seed=args.seed) if args.which in ('all', 'simulation'): bench_simulation(n_sources_list=args.n_sources_list, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_psf_matching.py000077500000000000000000000250241526452531300241060ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.psf_matching subpackage. The benchmarks cover the per-call cost of the kernel-making functions (``make_kernel`` and ``make_wiener_kernel`` for each penalty), the scaling of the kernel computation with the PSF size, the per-call cost of the window classes, and ``resize_psf`` for each spline order. Run ``python benchmarks/bench_psf_matching.py --help`` to see the available options. """ import argparse from functools import partial import numpy as np from astropy.modeling.models import Gaussian2D from bench_helpers import (add_memory_argument, print_environment, set_memory_mode, time_best) from photutils.psf_matching import (CosineBellWindow, HanningWindow, SplitCosineBellWindow, TopHatWindow, TukeyWindow, make_kernel, make_wiener_kernel, resize_psf) # The (label, function, kwargs) variants for the kernel benchmarks KERNEL_VARIANTS = ( ('make_kernel', make_kernel, {}), ('make_kernel (window)', make_kernel, {'window': SplitCosineBellWindow(alpha=0.15, beta=0.3)}), ('make_wiener_kernel', make_wiener_kernel, {}), ('make_wiener_kernel (laplacian)', make_wiener_kernel, {'penalty': 'laplacian'}), ('make_wiener_kernel (biharmonic)', make_wiener_kernel, {'penalty': 'biharmonic'}), ) def make_psf_pair(size, *, source_fraction=0.06, target_fraction=0.10): """ Return centered, normalized source and target Gaussian PSFs. The PSF widths scale with the array size so that the PSFs fill a constant fraction of the array. Parameters ---------- size : int The PSF array size; the arrays are ``(size, size)``. Must be odd. source_fraction : float, optional The source Gaussian sigma as a fraction of the array size. target_fraction : float, optional The target Gaussian sigma as a fraction of the array size. Returns ------- source_psf : 2D `~numpy.ndarray` The source (narrower) PSF. target_psf : 2D `~numpy.ndarray` The target (broader) PSF. """ cen = (size - 1) / 2.0 yy, xx = np.mgrid[0:size, 0:size] psfs = [] for fraction in (source_fraction, target_fraction): sigma = fraction * size psf = Gaussian2D(1.0, cen, cen, sigma, sigma)(xx, yy) psfs.append(psf / psf.sum()) return tuple(psfs) def run_kernel(func, source_psf, target_psf, n_iter, **kwargs): """ Compute a matching kernel repeatedly. Parameters ---------- func : callable The kernel-making function. source_psf : 2D `~numpy.ndarray` The source PSF. target_psf : 2D `~numpy.ndarray` The target PSF. n_iter : int The number of calls. **kwargs : dict, optional Additional keyword arguments passed to ``func`` (e.g., ``window`` or ``penalty``). """ for _ in range(n_iter): func(source_psf, target_psf, **kwargs) def bench_kernels(*, size=101, n_iter=20, repeats=3): """ Benchmark each kernel-making function variant. Parameters ---------- size : int, optional The PSF array size; the arrays are ``(size, size)``. Must be odd. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ source_psf, target_psf = make_psf_pair(size) print(f'\n== kernel functions ({size}x{size} PSFs, per-call time) ==') print(f'{"variant":>34}{"time":>12}') for label, func, kwargs in KERNEL_VARIANTS: bench = partial(run_kernel, func, source_psf, target_psf, n_iter, **kwargs) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{label:>34}{f"{t_call * 1e3:.3f}ms":>12}') def bench_size_scaling(*, sizes=(25, 51, 101, 201), n_iter=20, repeats=3): """ Benchmark the kernel computation versus the PSF size. Parameters ---------- sizes : tuple of int, optional The PSF array sizes. Each must be odd. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ variants = ( ('make_kernel', make_kernel, {}), ('make_wiener_kernel', make_wiener_kernel, {'penalty': 'laplacian'}), ) print('\n== scaling with the PSF size (per-call time) ==') header = f'{"size":>8}' for label, _, _ in variants: header += f'{label:>22}' print(header) for size in sizes: source_psf, target_psf = make_psf_pair(size) cells = '' for _, func, kwargs in variants: bench = partial(run_kernel, func, source_psf, target_psf, n_iter, **kwargs) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.3f}ms":>22}' print(f'{size:>8}{cells}') def run_window(window, shape, n_iter): """ Evaluate a window function repeatedly. Parameters ---------- window : callable The window class instance. shape : tuple of int The array shape passed to the window. n_iter : int The number of calls. """ for _ in range(n_iter): window(shape) def bench_windows(*, size=101, n_iter=100, repeats=3): """ Benchmark each window class. Parameters ---------- size : int, optional The window array size; the arrays are ``(size, size)``. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ windows = ( SplitCosineBellWindow(alpha=0.4, beta=0.3), TukeyWindow(alpha=0.5), HanningWindow(), CosineBellWindow(alpha=0.5), TopHatWindow(beta=0.4), ) shape = (size, size) print(f'\n== window classes ({size}x{size} array, per-call time) ==') print(f'{"class":>24}{"time":>12}') for window in windows: bench = partial(run_window, window, shape, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter print(f'{window.__class__.__name__:>24}' f'{f"{t_call * 1e3:.3f}ms":>12}') def run_resize(psf, ratio, order, n_iter): """ Resize a PSF repeatedly. Parameters ---------- psf : 2D `~numpy.ndarray` The PSF array. ratio : float The ratio of the input to output pixel scale. order : int The spline interpolation order. n_iter : int The number of calls. """ for _ in range(n_iter): resize_psf(psf, ratio, 1.0, order=order) def bench_resize(*, size=101, ratios=(0.5, 2.0), n_iter=20, repeats=3): """ Benchmark resize_psf for each spline order. Parameters ---------- size : int, optional The PSF array size; the array is ``(size, size)``. Must be odd. ratios : tuple of float, optional The ratios of the input to output pixel scale (a ratio > 1 upsamples the PSF). n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ psf, _ = make_psf_pair(size) orders = (1, 3, 5) print(f'\n== resize_psf ({size}x{size} PSF, per-call time) ==') header = f'{"ratio":>8}' for order in orders: header += f'{f"order={order}":>12}' print(header) for ratio in ratios: cells = '' for order in orders: bench = partial(run_resize, psf, ratio, order, n_iter) t_call = time_best(bench, repeats=repeats) / n_iter cells += f'{f"{t_call * 1e3:.3f}ms":>12}' print(f'{ratio:>8}{cells}') def parse_int_list(text): """ Parse a comma-separated list of positive odd integers. Parameters ---------- text : str The comma-separated integers (e.g., ``'25,101,201'``). Returns ------- result : list of int The parsed integers. """ values = [int(item) for item in text.split(',')] if any(value < 1 or value % 2 == 0 for value in values): msg = 'values must be positive odd integers' raise ValueError(msg) return values def main(): """ Run the photutils.psf_matching benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.psf_matching ' 'subpackage.') parser.add_argument('--size', type=int, default=101, help='PSF array size; must be odd ' '(default: %(default)s)') parser.add_argument('--sizes', type=parse_int_list, default=[25, 51, 101, 201], help='comma-separated PSF sizes for the ' 'scaling benchmark; each must be odd ' '(default: 25,51,101,201)') parser.add_argument('--n-iter', type=int, default=20, help='number of calls per timing ' '(default: %(default)s)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'kernels', 'size-scaling', 'windows', 'resize'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'kernels'): bench_kernels(size=args.size, n_iter=args.n_iter, repeats=args.repeats) if args.which in ('all', 'size-scaling'): bench_size_scaling(sizes=args.sizes, n_iter=args.n_iter, repeats=args.repeats) if args.which in ('all', 'windows'): bench_windows(size=args.size, n_iter=args.n_iter * 5, repeats=args.repeats) if args.which in ('all', 'resize'): bench_resize(size=args.size, n_iter=args.n_iter, repeats=args.repeats) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_segmentation.py000077500000000000000000000545731526452531300241540ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.segmentation subpackage. The benchmarks cover source detection (detect_threshold and detect_sources), source deblending (deblend_sources) across the threshold modes and thread counts, the combined SourceFinder class, SegmentationImage operations (relabeling, border-label removal, source masks, and polygons), SourceCatalog property calculations, the SourceCatalog n_threads keyword, and concurrent SourceCatalog runs across thread counts. Run ``python benchmarks/bench_segmentation.py --help`` to see the available options. """ import argparse import time from concurrent.futures import ThreadPoolExecutor from functools import partial from operator import attrgetter import numpy as np from astropy.convolution import convolve from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma from bench_helpers import (add_memory_argument, format_sweep_cells, parse_int_list, parse_thread_counts, print_environment, set_memory_mode, time_best) from photutils.datasets import make_wcs from photutils.segmentation import (SegmentationImage, SourceCatalog, SourceFinder, deblend_sources, detect_sources, detect_threshold, make_2dgaussian_kernel) from photutils.utils import circular_footprint from photutils.utils._optional_deps import HAS_RASTERIO, HAS_SHAPELY FWHM = 4.0 THRESHOLD = 5.0 N_PIXELS = 10 def make_source_image(n_sources, *, spacing=25, fwhm=FWHM, offset=6.0, noise_std=1.0, seed=0): """ Return an image with a grid of blended Gaussian-source pairs. Each grid cell contains a pair of overlapping Gaussian sources separated by ``offset`` pixels, so that each detected segment can be deblended into two sources. Parameters ---------- n_sources : int The total number of Gaussian sources. The number of grid cells is ``ceil(n_sources / 2)``. spacing : int, optional The grid cell size in pixels. fwhm : float, optional The FWHM of the Gaussian sources in pixels. offset : float, optional The separation of the two sources within a cell in pixels. noise_std : float, optional The standard deviation of the Gaussian noise. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. """ n_cells = (n_sources + 1) // 2 n_grid = int(np.ceil(np.sqrt(n_cells))) size = n_grid * spacing rng = np.random.default_rng(seed) data = rng.normal(0.0, noise_std, (size, size)) sigma = fwhm * gaussian_fwhm_to_sigma yy, xx = np.mgrid[0:spacing, 0:spacing] cen = (spacing - 1) / 2.0 half = offset / 2.0 for i in range(n_sources): cell, pair = divmod(i, 2) gy, gx = divmod(cell, n_grid) sign = 1.0 if pair == 0 else -1.0 amplitude = 100.0 if pair == 0 else 60.0 xc = cen + sign * half + rng.uniform(-0.5, 0.5) yc = cen + sign * half + rng.uniform(-0.5, 0.5) model = Gaussian2D(amplitude, xc, yc, sigma, sigma) x0 = gx * spacing y0 = gy * spacing data[y0:y0 + spacing, x0:x0 + spacing] += model(xx, yy) return data def make_inputs(n_sources, *, seed=0): """ Return the image, convolved image, and segmentation image. Parameters ---------- n_sources : int The total number of Gaussian sources. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The image. convolved_data : 2D `~numpy.ndarray` The convolved image. segm : `~photutils.segmentation.SegmentationImage` The segmentation image from detect_sources. """ data = make_source_image(n_sources, seed=seed) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) segm = detect_sources(convolved_data, THRESHOLD, N_PIXELS) return data, convolved_data, segm def bench_detect(*, n_sources=1000, repeats=3, seed=0): """ Benchmark detect_threshold and detect_sources. Parameters ---------- n_sources : int, optional The total number of Gaussian sources in the image. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data = make_source_image(n_sources, seed=seed) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) print(f'\n== detect_threshold / detect_sources ({n_sources} sources, ' f'{data.shape[0]}x{data.shape[1]} image) ==') print(f'{"benchmark":>34}{"time":>12}{"n_labels":>10}') bench = partial(detect_threshold, data, 2.0) t_best = time_best(bench, repeats=repeats) print(f'{"detect_threshold(n_sigma=2)":>34}{f"{t_best:.4f}s":>12}' f'{"n/a":>10}') for connectivity in (4, 8): segm = detect_sources(convolved_data, THRESHOLD, N_PIXELS, connectivity=connectivity) bench = partial(detect_sources, convolved_data, THRESHOLD, N_PIXELS, connectivity=connectivity) t_best = time_best(bench, repeats=repeats) name = f'detect_sources(connectivity={connectivity})' print(f'{name:>34}{f"{t_best:.4f}s":>12}{segm.n_labels:>10}') def bench_deblend(*, n_sources=1000, thread_counts=(1, 4), repeats=3, seed=0): """ Benchmark deblend_sources across modes and thread counts. Parameters ---------- n_sources : int, optional The total number of Gaussian sources in the image. thread_counts : tuple of int, optional The n_threads values (for mode='exponential'). repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, convolved_data, segm = make_inputs(n_sources, seed=seed) print(f'\n== deblend_sources ({n_sources} sources, ' f'{segm.n_labels} segments, {data.shape[0]}x{data.shape[1]} ' 'image) ==') print(f'{"benchmark":>36}{"time":>12}{"n_labels":>10}') for mode in ('linear', 'exponential', 'sinh'): segm_deblended = deblend_sources(convolved_data, segm, N_PIXELS, mode=mode) bench = partial(deblend_sources, convolved_data, segm, N_PIXELS, mode=mode) t_best = time_best(bench, repeats=repeats) name = f'mode={mode}' print(f'{name:>36}{f"{t_best:.4f}s":>12}' f'{segm_deblended.n_labels:>10}') for n_threads in thread_counts: if n_threads == 1: continue bench = partial(deblend_sources, convolved_data, segm, N_PIXELS, mode='exponential', n_threads=n_threads) t_best = time_best(bench, repeats=repeats) name = f'mode=exponential, n_threads={n_threads}' print(f'{name:>36}{f"{t_best:.4f}s":>12}{"":>10}') def bench_finder(*, n_sources=1000, repeats=3, seed=0): """ Benchmark the SourceFinder class (detection plus deblending). Parameters ---------- n_sources : int, optional The total number of Gaussian sources in the image. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data = make_source_image(n_sources, seed=seed) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) print(f'\n== SourceFinder ({n_sources} sources, ' f'{data.shape[0]}x{data.shape[1]} image) ==') print(f'{"benchmark":>24}{"time":>12}{"n_labels":>10}') for deblend in (False, True): finder = SourceFinder(n_pixels=N_PIXELS, deblend=deblend) segm = finder(convolved_data, THRESHOLD) bench = partial(finder, convolved_data, THRESHOLD) t_best = time_best(bench, repeats=repeats) name = f'deblend={deblend}' print(f'{name:>24}{f"{t_best:.4f}s":>12}{segm.n_labels:>10}') def bench_segmentation_image(*, n_sources=1000, repeats=3, seed=0): """ Benchmark SegmentationImage operations. Each timing constructs a fresh SegmentationImage so that cached properties are recomputed and mutating methods start from the same state. Parameters ---------- n_sources : int, optional The total number of Gaussian sources in the image. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ _, _, segm = make_inputs(n_sources, seed=seed) segm_data = segm.data # Nonconsecutive labels for the relabel benchmark segm_data_gaps = segm_data * 2 print(f'\n== SegmentationImage operations ({segm.n_labels} segments, ' f'{segm_data.shape[0]}x{segm_data.shape[1]} image) ==') print(f'{"benchmark":>36}{"time":>12}') footprint = circular_footprint(radius=5) def _run_properties(): segm = SegmentationImage(segm_data.copy()) _ = segm.slices, segm.areas, segm.bbox def _run_relabel(): SegmentationImage(segm_data_gaps.copy()).relabel_consecutive() def _run_remove_border(): segm = SegmentationImage(segm_data.copy()) segm.remove_border_labels(border_width=10) def _run_source_mask_square(): SegmentationImage(segm_data).make_source_mask(size=11) def _run_source_mask_circular(): SegmentationImage(segm_data).make_source_mask(footprint=footprint) benchmarks = [ ('labels + slices + areas + bbox', _run_properties), ('relabel_consecutive', _run_relabel), ('remove_border_labels(10)', _run_remove_border), ('make_source_mask(size=11)', _run_source_mask_square), ('make_source_mask(circular r=5)', _run_source_mask_circular), ] if HAS_RASTERIO and HAS_SHAPELY: def _run_polygons(): _ = SegmentationImage(segm_data).polygons benchmarks.append(('polygons', _run_polygons)) for name, func in benchmarks: t_best = time_best(func, repeats=repeats) print(f'{name:>36}{f"{t_best:.4f}s":>12}') def bench_catalog(*, n_sources=1000, repeats=3, seed=0): """ Benchmark all SourceCatalog property calculations. Every property listed by the SourceCatalog ``properties`` attribute is timed, followed by the method-based measurements (flux_radius, circular_photometry, kron_photometry, and to_table). The catalog is constructed with convolved data, error, background, and WCS inputs so that all properties compute real values. Each timing constructs a fresh SourceCatalog so that cached properties are recomputed; the reported times therefore include the computation of any dependent properties (e.g., centroid_win includes the Kron flux and flux_radius it depends on). Parameters ---------- n_sources : int, optional The total number of Gaussian sources in the image. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, convolved_data, segm = make_inputs(n_sources, seed=seed) error = np.ones_like(data) background = np.full(data.shape, 0.1) wcs = make_wcs(data.shape) def _make_catalog(*, local_bkg_width=0): return SourceCatalog(data, segm, convolved_data=convolved_data, error=error, background=background, wcs=wcs, local_bkg_width=local_bkg_width) catalog = _make_catalog() # warm up shared segmentation-image caches print(f'\n== SourceCatalog ({segm.n_labels} segments, ' f'{data.shape[0]}x{data.shape[1]} image; times include ' 'dependent properties) ==') print(f'{"benchmark":>32}{"time":>12}') benchmarks = [('constructor', lambda _cat: None)] benchmarks.extend((name, attrgetter(name)) for name in catalog.properties) benchmarks.extend([ ('flux_radius(0.5)', lambda cat: cat.flux_radius(0.5)), ('circular_photometry(5)', lambda cat: cat.circular_photometry(5.0)), ('kron_photometry((2.5, 1.4))', lambda cat: cat.kron_photometry((2.5, 1.4))), ('to_table (default columns)', lambda cat: cat.to_table()), ]) for name, func in benchmarks: def _bench(func=func): func(_make_catalog()) t_best = time_best(_bench, repeats=repeats) print(f'{name:>32}{f"{t_best:.4f}s":>12}') # The local background is measured only for a nonzero # local_bkg_width, which the default catalog does not set local_bkg_width = 24 benchmarks = [ (f'local_background (width {local_bkg_width})', lambda cat: cat.local_background), (f'to_table (width {local_bkg_width})', lambda cat: cat.to_table()), ] for name, func in benchmarks: def _bench(func=func): func(_make_catalog(local_bkg_width=local_bkg_width)) t_best = time_best(_bench, repeats=repeats) print(f'{name:>32}{f"{t_best:.4f}s":>12}') def run_catalog_concurrent(make_catalog, n_calls, n_threads): """ Run concurrent SourceCatalog measurement jobs. Parameters ---------- make_catalog : callable The zero-argument callable returning a fresh SourceCatalog. n_calls : int The total number of jobs. n_threads : int The number of worker threads. """ def _job(): make_catalog().to_table() with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(_job) for _ in range(n_calls)] for future in futures: future.result() def bench_catalog_threads(*, n_sources=1000, n_calls=8, thread_counts=(1, 2, 4, 8), repeats=3, seed=0): """ Benchmark concurrent SourceCatalog measurement runs. Each job constructs a fresh SourceCatalog over shared input arrays and computes the default to_table columns. Parameters ---------- n_sources : int, optional The total number of Gaussian sources in the image. n_calls : int, optional The total number of catalog jobs per timing. thread_counts : tuple of int, optional The numbers of worker threads. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, convolved_data, segm = make_inputs(n_sources, seed=seed) error = np.ones_like(data) def _make_catalog(): return SourceCatalog(data, segm, convolved_data=convolved_data, error=error) _make_catalog().to_table() # warm up times = [] for n_threads in thread_counts: bench = partial(run_catalog_concurrent, _make_catalog, n_calls, n_threads) times.append(time_best(bench, repeats=repeats)) print(f'\n== SourceCatalog thread scaling ({segm.n_labels} segments, ' f'{data.shape[0]}x{data.shape[1]} image, {n_calls} concurrent ' 'to_table jobs) ==') header = ''.join(f'{f"{n} thr":>18}' for n in thread_counts) print(header) print(''.join(f'{cell:>18}' for cell in format_sweep_cells(times))) def bench_catalog_n_threads(*, n_sources=1000, thread_counts=(1, 2, 4, 8), repeats=3, seed=0): """ Benchmark the SourceCatalog n_threads keyword. Each timing constructs a fresh SourceCatalog with the given ``n_threads`` and, outside the timed region, computes the prerequisites of the measurement (e.g., the isophotal centroid and shape parameters for the Kron radius, or the Kron flux for the flux radius), so that only the measurement itself is timed. The default ``to_table`` columns are timed from a cold catalog, so that row shows the end-to-end effect. Parameters ---------- n_sources : int, optional The total number of Gaussian sources in the image. thread_counts : tuple of int, optional The ``n_threads`` values. repeats : int, optional The number of repeats for each timing (best time is kept). seed : int, optional The random number generator seed. """ data, convolved_data, segm = make_inputs(n_sources, seed=seed) error = np.ones_like(data) def _make_catalog(n_threads): return SourceCatalog(data, segm, convolved_data=convolved_data, error=error, n_threads=n_threads) def _time_measurement(func, n_threads, *, warm=()): best = np.inf for _ in range(repeats): cat = _make_catalog(n_threads) for item in warm: if isinstance(item, str): getattr(cat, item) else: item(cat) t0 = time.perf_counter() func(cat) best = min(best, time.perf_counter() - t0) return best _make_catalog(1).to_table() # warm up shape = ('centroid', 'semimajor_axis', 'semiminor_axis', 'orientation', 'ellipse_cxx', 'ellipse_cxy', 'ellipse_cyy') half_light = [lambda cat: cat.flux_radius(0.5)] benchmarks = [ ('moments', attrgetter('moments'), ('bbox_xmin',)), ('moments_central', attrgetter('moments_central'), ('cutout_centroid',)), ('centroid_err', attrgetter('centroid_err'), ('covariance',)), ('segment_flux (+ error)', attrgetter('segment_flux'), ('bbox_xmin',)), ('min_value_xindex', attrgetter('min_value_xindex'), ('bbox_xmin',)), ('perimeter', attrgetter('perimeter'), ('bbox_xmin',)), ('centroid_quad', attrgetter('centroid_quad'), ('bbox_xmin',)), ('circular_photometry(5)', lambda cat: cat.circular_photometry(5.0), ('centroid',)), ('kron_radius', attrgetter('kron_radius'), shape), ('kron_flux (incl. kron_radius)', attrgetter('kron_flux'), shape), ('kron_photometry((2.5, 1.4))', lambda cat: cat.kron_photometry((2.5, 1.4)), ('kron_radius',)), ('flux_radius(0.5)', lambda cat: cat.flux_radius(0.5), ('kron_flux',)), ('centroid_win', attrgetter('centroid_win'), half_light), ('to_table (default columns)', lambda cat: cat.to_table(), ()), ] print(f'\n== SourceCatalog n_threads scaling ({segm.n_labels} ' f'segments, {data.shape[0]}x{data.shape[1]} image; ' 'prerequisites precomputed except for to_table) ==') header = f'{"measurement":>32}' header += ''.join(f'{f"n={n}":>18}' for n in thread_counts) print(header) for name, func, warm in benchmarks: times = [_time_measurement(func, n_threads, warm=warm) for n_threads in thread_counts] cells = ''.join(f'{cell:>18}' for cell in format_sweep_cells(times)) print(f'{name:>32}{cells}') def main(): """ Run the photutils.segmentation benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.segmentation subpackage.') parser.add_argument('--n-sources', type=int, default=1000, help='total number of Gaussian sources in the ' 'image (default: %(default)s)') parser.add_argument('--deblend-n-threads', type=parse_int_list, default=[1, 4], help='comma-separated n_threads values for the ' 'deblending benchmark (default: 1,4)') parser.add_argument('--n-calls', type=int, default=8, help='number of concurrent catalog jobs for the ' 'thread-scaling benchmark ' '(default: %(default)s)') parser.add_argument('--n-threads', type=parse_thread_counts, default=[1, 2, 4, 8], help='comma-separated thread counts for the ' 'thread-scaling and n_threads benchmarks ' '(default: 1,2,4,8)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'detect', 'deblend', 'finder', 'segmimage', 'catalog', 'n-threads', 'threads'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'detect'): bench_detect(n_sources=args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'deblend'): bench_deblend(n_sources=args.n_sources, thread_counts=args.deblend_n_threads, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'finder'): bench_finder(n_sources=args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'segmimage'): bench_segmentation_image(n_sources=args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'catalog'): bench_catalog(n_sources=args.n_sources, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'n-threads'): bench_catalog_n_threads(n_sources=args.n_sources, thread_counts=args.n_threads, repeats=args.repeats, seed=args.seed) if args.which in ('all', 'threads'): bench_catalog_threads(n_sources=args.n_sources, n_calls=args.n_calls, thread_counts=args.n_threads, repeats=args.repeats, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_utils.py000077500000000000000000000416301526452531300226050ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the photutils.utils subpackage. The benchmarks cover the scaling of ``ImageDepth`` with the number of apertures (including the nonoverlapping mode and concurrent calls from multiple threads), ``ShepardIDWInterpolator`` construction and evaluation, cutout generation, ``calc_total_error``, the NaN-ignoring statistics functions, and random-coordinate generation with a minimum separation. Run ``python benchmarks/bench_utils.py --help`` to see the available options. """ import argparse import warnings from concurrent.futures import ThreadPoolExecutor from functools import partial import numpy as np from astropy.utils.exceptions import AstropyUserWarning from bench_helpers import (add_memory_argument, format_sweep_cells, make_image, parse_thread_counts, print_environment, set_memory_mode, time_best) from photutils.utils import (CutoutImage, ImageDepth, ShepardIDWInterpolator, calc_total_error) from photutils.utils._coords import make_random_xycoords from photutils.utils._stats import (nanmax, nanmean, nanmedian, nanmin, nanstd, nansum, nanvar) from photutils.utils.cutouts import _make_cutouts APER_RADIUS = 4.0 def make_depth_inputs(size, *, seed=0): """ Return a noise image and a source mask for the ImageDepth benchmarks. The mask contains compact square "sources" covering ~5% of the image, so that plenty of unmasked area remains after the mask is dilated by the aperture radius. Parameters ---------- size : int The image size; the image is ``(size, size)``. seed : int, optional The random number generator seed. Returns ------- data : 2D `~numpy.ndarray` The noise image. mask : 2D bool `~numpy.ndarray` The source mask. """ data = make_image((size, size), seed=seed) rng = np.random.default_rng(seed) mask = np.zeros(data.shape, dtype=bool) blob_size = 20 n_blobs = max(1, (size * size) // (20 * blob_size**2)) for _ in range(n_blobs): y = rng.integers(0, size - blob_size) x = rng.integers(0, size - blob_size) mask[y:y + blob_size, x:x + blob_size] = True return data, mask def make_depth(n_apertures, *, overlap=True): """ Return a seeded ImageDepth instance for the benchmarks. Parameters ---------- n_apertures : int The number of circular apertures. overlap : bool, optional Whether to allow the apertures to overlap. Returns ------- depth : `~photutils.utils.ImageDepth` The ImageDepth instance. """ return ImageDepth(APER_RADIUS, n_sigma=5.0, n_apertures=n_apertures, n_iters=2, mask_pad=2, overlap=overlap, seed=0, progress_bar=False) def run_image_depth(depth, data, mask, n_iter): """ Run an ImageDepth calculation repeatedly. Parameters ---------- depth : `~photutils.utils.ImageDepth` The ImageDepth instance. data : 2D `~numpy.ndarray` The image. mask : 2D bool `~numpy.ndarray` The source mask. n_iter : int The number of calls. """ with warnings.catch_warnings(): # The nonoverlapping mode may warn when fewer apertures than # requested can be placed; that is irrelevant for timing warnings.simplefilter('ignore', AstropyUserWarning) for _ in range(n_iter): depth(data, mask) def bench_image_depth(*, size=1000, n_apertures_list=(100, 500, 2000), n_iter=1, repeats=3): """ Benchmark ImageDepth versus the number of apertures. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_apertures_list : tuple of int, optional The numbers of apertures to place. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ data, mask = make_depth_inputs(size) run_image_depth(make_depth(50), data, mask, 1) # warm up print(f'\n== ImageDepth ({size}x{size} image, radius ' f'{APER_RADIUS}, 2 iterations, per-call time) ==') print(f'{"n_apertures":>12}{"overlap":>16}{"no overlap":>16}') for n_apertures in n_apertures_list: times = [] for overlap in (True, False): depth = make_depth(n_apertures, overlap=overlap) bench = partial(run_image_depth, depth, data, mask, n_iter) times.append(time_best(bench, repeats=repeats) / n_iter) print(f'{n_apertures:>12}' f'{f"{times[0] * 1e3:.2f}ms":>16}' f'{f"{times[1] * 1e3:.2f}ms":>16}') def run_image_depth_concurrent(depth, data, mask, n_calls, n_threads): """ Run concurrent ImageDepth calls on a shared instance. Parameters ---------- depth : `~photutils.utils.ImageDepth` The shared ImageDepth instance. data : 2D `~numpy.ndarray` The image. mask : 2D bool `~numpy.ndarray` The source mask. n_calls : int The total number of calls. n_threads : int The number of worker threads. """ with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(depth, data, mask) for _ in range(n_calls)] for future in futures: future.result() def bench_depth_threads(*, size=500, n_apertures=500, n_calls=8, thread_counts=(1, 2, 4, 8), repeats=3): """ Benchmark concurrent ImageDepth calls on a shared instance. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_apertures : int, optional The number of circular apertures. n_calls : int, optional The total number of ImageDepth calls per timing. thread_counts : tuple of int, optional The numbers of worker threads. repeats : int, optional The number of repeats for each timing (best time is kept). """ data, mask = make_depth_inputs(size) depth = make_depth(n_apertures) depth(data, mask) # warm up times = [] for n_threads in thread_counts: bench = partial(run_image_depth_concurrent, depth, data, mask, n_calls, n_threads) times.append(time_best(bench, repeats=repeats)) print(f'\n== ImageDepth thread scaling ({size}x{size} image, ' f'{n_apertures} apertures, {n_calls} concurrent calls on a ' 'shared instance) ==') header = ''.join(f'{f"{n} thr":>18}' for n in thread_counts) print(header) print(''.join(f'{cell:>18}' for cell in format_sweep_cells(times))) def bench_idw(*, n_coords_list=(1_000, 10_000, 100_000), n_positions_list=(100, 1_000, 10_000), n_neighbors_list=(1, 8, 64), repeats=3): """ Benchmark ShepardIDWInterpolator construction and evaluation. Parameters ---------- n_coords_list : tuple of int, optional The numbers of known data points for the construction benchmark. n_positions_list : tuple of int, optional The numbers of query positions for the evaluation benchmark. n_neighbors_list : tuple of int, optional The numbers of neighbors for the evaluation benchmark. repeats : int, optional The number of repeats for each timing (best time is kept). """ rng = np.random.default_rng(0) print('\n== ShepardIDWInterpolator construction (2D coords) ==') print(f'{"n_coords":>12}{"time":>12}') for n_coords in n_coords_list: coords = rng.random((n_coords, 2)) values = np.sin(coords[:, 0] + coords[:, 1]) bench = partial(ShepardIDWInterpolator, coords, values) t_call = time_best(bench, repeats=repeats) print(f'{n_coords:>12}{f"{t_call * 1e3:.2f}ms":>12}') n_coords = 10_000 coords = rng.random((n_coords, 2)) values = np.sin(coords[:, 0] + coords[:, 1]) interp = ShepardIDWInterpolator(coords, values) print(f'\n== ShepardIDWInterpolator evaluation ({n_coords} coords, ' '8 neighbors) ==') print(f'{"n_positions":>12}{"time":>12}{"per-pos":>12}') for n_positions in n_positions_list: positions = rng.random((n_positions, 2)) bench = partial(interp, positions) t_call = time_best(bench, repeats=repeats) print(f'{n_positions:>12}{f"{t_call * 1e3:.2f}ms":>12}' f'{f"{t_call / n_positions * 1e6:.1f}us":>12}') n_positions = 1000 positions = rng.random((n_positions, 2)) print(f'\n== ShepardIDWInterpolator evaluation ({n_coords} coords, ' f'{n_positions} positions) ==') print(f'{"n_neighbors":>12}{"time":>12}') for n_neighbors in n_neighbors_list: bench = partial(interp, positions, n_neighbors=n_neighbors) t_call = time_best(bench, repeats=repeats) print(f'{n_neighbors:>12}{f"{t_call * 1e3:.2f}ms":>12}') def run_cutout_image(data, positions, shape, n_iter): """ Create single cutouts repeatedly with CutoutImage. Parameters ---------- data : 2D `~numpy.ndarray` The image. positions : 2D `~numpy.ndarray` The (y, x) cutout positions. shape : 2-tuple of int The cutout shape. n_iter : int The number of passes over the positions. """ for _ in range(n_iter): for position in positions: CutoutImage(data, position, shape, mode='partial') def bench_cutouts(*, size=1000, n_sources_list=(100, 1_000, 10_000), cutout_shape=(11, 11), repeats=3): """ Benchmark cutout generation. Parameters ---------- size : int, optional The image size; the image is ``(size, size)``. n_sources_list : tuple of int, optional The numbers of cutout positions. cutout_shape : 2-tuple of int, optional The cutout shape. repeats : int, optional The number of repeats for each timing (best time is kept). """ data = make_image((size, size)) rng = np.random.default_rng(0) ny, nx = cutout_shape print(f'\n== _make_cutouts ({size}x{size} image, {ny}x{nx} ' 'cutouts) ==') print(f'{"n_sources":>12}{"time":>12}{"per-source":>14}') for n_sources in n_sources_list: xpos = rng.uniform(0, size - 1, n_sources) ypos = rng.uniform(0, size - 1, n_sources) bench = partial(_make_cutouts, data, xpos, ypos, cutout_shape) t_call = time_best(bench, repeats=repeats) print(f'{n_sources:>12}{f"{t_call * 1e3:.2f}ms":>12}' f'{f"{t_call / n_sources * 1e6:.1f}us":>14}') n_sources = 1000 positions = np.column_stack( [rng.uniform(0, size - 1, n_sources), rng.uniform(0, size - 1, n_sources)]) n_iter = 3 bench = partial(run_cutout_image, data, positions, (25, 25), n_iter) t_call = time_best(bench, repeats=repeats) / (n_iter * n_sources) print('\n== CutoutImage (25x25 cutouts, per-cutout time) ==') print(f'{f"{t_call * 1e6:.1f}us":>12}') def bench_total_error(*, sizes=(500, 1000, 2000), n_iter=3, repeats=3): """ Benchmark calc_total_error versus image size. Parameters ---------- sizes : tuple of int, optional The image sizes. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ print('\n== calc_total_error (per-call time) ==') print(f'{"size":>12}{"scalar gain":>14}{"2D gain":>14}') for size in sizes: data = make_image((size, size)) bkg_error = np.sqrt(np.abs(data)) gain_image = np.full(data.shape, 2.0) times = [] for gain in (2.0, gain_image): def run(data=data, bkg_error=bkg_error, gain=gain): for _ in range(n_iter): calc_total_error(data, bkg_error, gain) times.append(time_best(run, repeats=repeats) / n_iter) print(f'{size:>12}' f'{f"{times[0] * 1e3:.2f}ms":>14}' f'{f"{times[1] * 1e3:.2f}ms":>14}') def bench_nan_stats(*, size=2000, n_iter=3, repeats=3): """ Benchmark the NaN-ignoring statistics functions. Float64 arrays dispatch to bottleneck (if installed); other dtypes use NumPy. Parameters ---------- size : int, optional The array size; the array is ``(size, size)``. n_iter : int, optional The number of calls per timing; the per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ data = make_image((size, size)) rng = np.random.default_rng(0) nan_mask = rng.random(data.shape) < 0.01 data[nan_mask] = np.nan data32 = data.astype(np.float32) funcs = [('nansum', nansum), ('nanmin', nanmin), ('nanmax', nanmax), ('nanmean', nanmean), ('nanmedian', nanmedian), ('nanstd', nanstd), ('nanvar', nanvar)] print(f'\n== NaN-ignoring statistics ({size}x{size} array, 1% NaN, ' 'per-call time) ==') print(f'{"function":>12}{"float64":>12}{"float32":>12}') for name, func in funcs: times = [] for arr in (data, data32): def run(func=func, arr=arr): for _ in range(n_iter): func(arr) times.append(time_best(run, repeats=repeats) / n_iter) print(f'{name:>12}' f'{f"{times[0] * 1e3:.2f}ms":>12}' f'{f"{times[1] * 1e3:.2f}ms":>12}') def bench_random_coords(*, size=1000, n_coords_list=(1_000, 10_000), min_separations=(0.0, 5.0), repeats=3): """ Benchmark make_random_xycoords with and without a minimum separation. Parameters ---------- size : int, optional The coordinate range; coordinates span ``(0, size)`` in x and y. n_coords_list : tuple of int, optional The numbers of coordinates to generate. min_separations : tuple of float, optional The minimum separations to apply. repeats : int, optional The number of repeats for each timing (best time is kept). """ print('\n== make_random_xycoords (per-call time) ==') header = f'{"n_coords":>12}' header += ''.join(f'{f"min_sep={ms:g}":>16}' for ms in min_separations) print(header) for n_coords in n_coords_list: cells = [f'{n_coords:>12}'] for min_separation in min_separations: bench = partial(make_random_xycoords, n_coords, (0, size), (0, size), min_separation=min_separation, seed=0) t_call = time_best(bench, repeats=repeats) cells.append(f'{f"{t_call * 1e3:.2f}ms":>16}') print(''.join(cells)) def main(): """ Run the photutils.utils benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the photutils.utils subpackage.') parser.add_argument('--size', type=int, default=1000, help='image size for the ImageDepth and cutout ' 'benchmarks (default: %(default)s)') parser.add_argument('--n-threads', type=parse_thread_counts, default=[1, 2, 4, 8], help='comma-separated thread counts for the ' 'ImageDepth thread-scaling benchmark ' '(default: 1,2,4,8)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing; the best ' 'time is reported (default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'image-depth', 'depth-threads', 'idw', 'cutouts', 'total-error', 'nan-stats', 'random-coords'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'image-depth'): bench_image_depth(size=args.size, repeats=args.repeats) if args.which in ('all', 'depth-threads'): bench_depth_threads(thread_counts=args.n_threads, repeats=args.repeats) if args.which in ('all', 'idw'): bench_idw(repeats=args.repeats) if args.which in ('all', 'cutouts'): bench_cutouts(size=args.size, repeats=args.repeats) if args.which in ('all', 'total-error'): bench_total_error(repeats=args.repeats) if args.which in ('all', 'nan-stats'): bench_nan_stats(repeats=args.repeats) if args.which in ('all', 'random-coords'): bench_random_coords(repeats=args.repeats) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/bench_wcs_helpers.py000077500000000000000000000322631526452531300237650ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Benchmarks for the local WCS helper functions. The benchmarks cover the per-call cost of the scalar helpers on astropy TAN, TAN-SIP, and gwcs transforms, the vectorized Jacobians and pixel areas versus the number of positions (with the speedup over a per-source loop of the scalar mean-scale helper), the full-frame pixel-area map versus image size and grid step, and the aperture ``to_pixel`` and ``to_sky`` conversions that use the helpers. Run ``python benchmarks/bench_wcs_helpers.py --help`` to see the available options. """ import argparse from functools import partial import astropy.units as u import numpy as np from astropy.io.fits import Header from astropy.wcs import WCS from bench_helpers import (add_memory_argument, parse_int_list, print_environment, set_memory_mode, time_best) from photutils.aperture import (CircularAperture, EllipticalAperture, SkyCircularAperture, SkyEllipticalAperture) from photutils.datasets import make_gwcs, make_wcs from photutils.utils import compute_pixel_area_map, compute_pixel_areas from photutils.utils._wcs_helpers import (compute_pixel_to_sky_jacobians, pixel_shape_to_sky_svd, pixel_to_sky_mean_scale, pixel_to_sky_svd_scales, sky_shape_to_pixel_svd, sky_to_pixel_mean_scale, sky_to_pixel_svd_scales) # The per-source loops are capped at this many calls and the time is # scaled to the requested number of positions MAX_LOOP_CALLS = 500 def make_sip_wcs(shape, *, coeff=1e-6): """ Return a TAN-SIP WCS with a small quadratic distortion. Parameters ---------- shape : tuple of int The ``(ny, nx)`` image shape. coeff : float, optional The quadratic SIP coefficient. Returns ------- wcs : `~astropy.wcs.WCS` The distorted WCS. """ header = Header() header['NAXIS'] = 2 header['NAXIS1'] = shape[1] header['NAXIS2'] = shape[0] header['CRPIX1'] = shape[1] / 2 header['CRPIX2'] = shape[0] / 2 header['CRVAL1'] = 197.8925 header['CRVAL2'] = -1.36555556 header['CTYPE1'] = 'RA---TAN-SIP' header['CTYPE2'] = 'DEC--TAN-SIP' cdelt = 0.1 / 3600 header['CD1_1'] = -cdelt header['CD1_2'] = 0.0 header['CD2_1'] = 0.0 header['CD2_2'] = cdelt header['A_ORDER'] = 2 header['A_2_0'] = coeff header['B_ORDER'] = 2 header['B_0_2'] = coeff return WCS(header) def make_wcs_cases(shape): """ Return the (name, wcs) pairs benchmarked. Parameters ---------- shape : tuple of int The ``(ny, nx)`` image shape. Returns ------- result : list of (str, wcs) tuples The WCS name and instance pairs. """ return [('TAN', make_wcs(shape)), ('TAN-SIP', make_sip_wcs(shape)), ('gwcs', make_gwcs(shape))] def make_positions(shape, n_positions, *, seed=0): """ Return random pixel positions inside the image. Parameters ---------- shape : tuple of int The ``(ny, nx)`` image shape. n_positions : int The number of positions. seed : int, optional The random number generator seed. Returns ------- x, y : 1D `~numpy.ndarray` The pixel coordinates. """ rng = np.random.default_rng(seed) margin = 20.0 x = rng.uniform(margin, shape[1] - margin, n_positions) y = rng.uniform(margin, shape[0] - margin, n_positions) return x, y def make_scalar_cases(skycoord, pixcoord, wcs): """ Return the (name, callable) pairs for the scalar helpers. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The sky position for the sky-input helpers. pixcoord : tuple of float The ``(x, y)`` pixel position for the pixel-input helpers. wcs : WCS object The WCS transformation. Returns ------- result : list of (str, callable) tuples The helper name and zero-argument callable pairs. """ width, height, angle = 2.0, 1.0, 0.5 return [ ('sky->pix mean scale', partial(sky_to_pixel_mean_scale, wcs, skycoord)), ('sky->pix SVD scales', partial(sky_to_pixel_svd_scales, wcs, skycoord)), ('sky->pix shape SVD', partial(sky_shape_to_pixel_svd, wcs, skycoord, width, height, angle)), ('pix->sky mean scale', partial(pixel_to_sky_mean_scale, wcs, pixcoord)), ('pix->sky SVD scales', partial(pixel_to_sky_svd_scales, wcs, pixcoord)), ('pix->sky shape SVD', partial(pixel_shape_to_sky_svd, wcs, pixcoord, width, height, angle)), ] def bench_scalar_helpers(*, shape=(2000, 2000), n_iter=50, repeats=3): """ Benchmark the per-call cost of the scalar helper functions. Parameters ---------- shape : tuple of int, optional The ``(ny, nx)`` image shape. n_iter : int, optional The number of calls per timing. The per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ pixcoord = (shape[1] / 2, shape[0] / 2) cases = make_wcs_cases(shape) names = [name for name, _ in cases] print('\n== Scalar WCS helpers (per-call time) ==') print(f'{"function":>20}' + ''.join(f'{name:>12}' for name in names)) rows = {} for _, wcs in cases: skycoord = wcs.pixel_to_world(*pixcoord) for name, func in make_scalar_cases(skycoord, pixcoord, wcs): def run(func=func): for _ in range(n_iter): func() t_call = time_best(run, repeats=repeats) / n_iter rows.setdefault(name, []).append(f'{t_call * 1e6:.0f}us') for name, cells in rows.items(): print(f'{name:>20}' + ''.join(f'{cell:>12}' for cell in cells)) def bench_vectorized_helpers(*, shape=(2000, 2000), n_positions_list=(100, 1_000, 10_000, 100_000), repeats=3): """ Benchmark the vectorized helpers versus the number of positions. The speedup of the vectorized Jacobians over a per-source loop of the scalar mean-scale helper, which does the same WCS work, is also reported. The loops are capped at ``MAX_LOOP_CALLS`` calls and scaled to the number of positions. Parameters ---------- shape : tuple of int, optional The ``(ny, nx)`` image shape. n_positions_list : tuple of int, optional The numbers of positions. repeats : int, optional The number of repeats for each timing (best time is kept). """ vectorized = [ ('Jacobians', compute_pixel_to_sky_jacobians), ('pixel areas', compute_pixel_areas), ] for wcs_name, wcs in make_wcs_cases(shape): print(f'\n== Vectorized WCS helpers ({wcs_name}) ==') print(f'{"n_positions":>12}{"Jacobians":>12}{"pixel areas":>14}' f'{"loop speedup":>14}') for n_positions in n_positions_list: x, y = make_positions(shape, n_positions) times = [time_best(partial(func, wcs, x, y), repeats=repeats) for _, func in vectorized] # Per-source loop of the scalar mean-scale helper, scaled # to n_positions n_calls = min(n_positions, MAX_LOOP_CALLS) def loop(x=x, y=y, wcs=wcs, n_calls=n_calls): for i in range(n_calls): pixel_to_sky_mean_scale(wcs, (x[i], y[i])) t_loop = time_best(loop, repeats=1) * n_positions / n_calls cells = [f'{t * 1e3:.2f}ms' for t in times] cells.append(f'{t_loop / times[0]:.0f}x') print(f'{n_positions:>12}{cells[0]:>12}{cells[1]:>14}' f'{cells[2]:>14}') def bench_pixel_area_map(*, sizes=(512, 2048, 4096), steps=(8, 16, 32, 64), repeats=3): """ Benchmark the full-frame pixel-area map versus image size and grid step. Parameters ---------- sizes : tuple of int, optional The image sizes. Each image is ``(size, size)``. steps : tuple of int, optional The coarse-grid steps in pixels. Every step must be at most ``min(sizes) // 8``, the largest step the function allows. repeats : int, optional The number of repeats for each timing (best time is kept). """ for wcs_name in ('TAN-SIP', 'gwcs'): print(f'\n== compute_pixel_area_map ({wcs_name}) ==') print(f'{"size":>8}' + ''.join(f'{f"step={s}":>12}' for s in steps)) for size in sizes: shape = (size, size) wcs = dict(make_wcs_cases(shape))[wcs_name] cells = [] for step in steps: func = partial(compute_pixel_area_map, wcs, shape, step=step) t = time_best(func, repeats=repeats) cells.append(f'{t * 1e3:.1f}ms') print(f'{size:>8}' + ''.join(f'{cell:>12}' for cell in cells)) def bench_aperture_conversions(*, shape=(2000, 2000), n_iter=20, repeats=3): """ Benchmark the aperture ``to_pixel`` and ``to_sky`` conversions that use the WCS helpers. Parameters ---------- shape : tuple of int, optional The ``(ny, nx)`` image shape. n_iter : int, optional The number of calls per timing. The per-call time is reported. repeats : int, optional The number of repeats for each timing (best time is kept). """ x0, y0 = shape[1] / 2, shape[0] / 2 cases = make_wcs_cases(shape) names = [name for name, _ in cases] print('\n== Aperture conversions (per-call time) ==') print(f'{"conversion":>32}' + ''.join(f'{name:>12}' for name in names)) rows = {} for _, wcs in cases: skycoord = wcs.pixel_to_world(x0, y0) circle = CircularAperture((x0, y0), r=5.0) ellipse = EllipticalAperture((x0, y0), a=5.0, b=3.0, theta=0.5) sky_circle = SkyCircularAperture(skycoord, r=0.5 * u.arcsec) sky_ellipse = SkyEllipticalAperture(skycoord, a=0.5 * u.arcsec, b=0.3 * u.arcsec, theta=30 * u.deg) funcs = [ ('CircularAperture.to_sky', partial(circle.to_sky, wcs)), ('CircularAperture.to_sky (ellipse)', partial(circle.to_sky, wcs, as_ellipse=True)), ('SkyCircularAperture.to_pixel', partial(sky_circle.to_pixel, wcs)), ('SkyCircularAperture.to_pixel (ellipse)', partial(sky_circle.to_pixel, wcs, as_ellipse=True)), ('EllipticalAperture.to_sky', partial(ellipse.to_sky, wcs)), ('SkyEllipticalAperture.to_pixel', partial(sky_ellipse.to_pixel, wcs)), ] for name, func in funcs: def run(func=func): for _ in range(n_iter): func() t_call = time_best(run, repeats=repeats) / n_iter rows.setdefault(name, []).append(f'{t_call * 1e6:.0f}us') for name, cells in rows.items(): print(f'{name:>32}' + ''.join(f'{cell:>12}' for cell in cells)) def main(): """ Run the WCS helper benchmarks. """ parser = argparse.ArgumentParser( description='Benchmarks for the local WCS helper functions.') parser.add_argument('--n-positions', type=parse_int_list, default=[100, 1_000, 10_000, 100_000], help='comma-separated numbers of positions for ' 'the vectorized-helper benchmark ' '(default: 100,1000,10000,100000)') parser.add_argument('--sizes', type=parse_int_list, default=[512, 2048, 4096], help='comma-separated image sizes for the ' 'pixel-area-map benchmark ' '(default: 512,2048,4096)') parser.add_argument('--repeats', type=int, default=3, help='number of repeats per timing, of which ' 'the best time is reported ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'scalar', 'vectorized', 'area-map', 'apertures'], help='which benchmark to run ' '(default: %(default)s)') add_memory_argument(parser) args = parser.parse_args() set_memory_mode(args.memory) print_environment() if args.which in ('all', 'scalar'): bench_scalar_helpers(repeats=args.repeats) if args.which in ('all', 'vectorized'): bench_vectorized_helpers(n_positions_list=args.n_positions, repeats=args.repeats) if args.which in ('all', 'area-map'): bench_pixel_area_map(sizes=args.sizes, repeats=args.repeats) if args.which in ('all', 'apertures'): bench_aperture_conversions(repeats=args.repeats) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/mc_catalog_err.py000077500000000000000000000354151526452531300232530ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Monte Carlo verification of the SourceCatalog error properties. This is a validation script rather than a timing benchmark. It checks every reported error of ``SourceCatalog`` against the scatter of the measured quantity over many noise realizations. Two checks are run: * the covariance transport alone: pixel positions are drawn from a known pixel covariance, converted with the high-level WCS interface, and their East and North scatter about the center is compared with the errors that ``SourceCatalog`` transports to the sky with the local WCS Jacobian, for several WCS types. The same transport serves the isophotal, windowed, and quadratic sky centroid errors. * end to end: a Gaussian source is simulated many times with Gaussian noise and measured with ``SourceCatalog`` given an ``error`` array and a sheared, anisotropic WCS. The scatter of the measured values is compared with the mean reported error for the isophotal, windowed, and quadratic centroids in pixel and sky coordinates, and for the segment and Kron fluxes. The end-to-end check is sensitive to the handling of negative pixels. ``SourceCatalog`` zeroes negative data values within a segment before computing moments, which makes the isophotal centroid a clipped estimator when the segment extends into wings below the noise. The reported error, a linear propagation, is then a few percent high. Use a footprint whose edge is well above the noise (the defaults) to see agreement, or ``--radius 8`` with the default amplitude to see the effect. The quadratic centroid error is a delta-method linearization of the 3x3 peak fit evaluated at the noisy fit coefficients. It agrees at the default amplitude but is several percent high when the peak is only a few tens of sigma above the noise (``--amplitude 40``), whatever the segment size. The Kron flux scatter also includes the variation of the Kron aperture itself, whose center, shape, and radius are measured from each realization. The reported error propagates the ``error`` array over the aperture of that realization only. At the default signal-to-noise ratio, the aperture varies little and the ratio stays close to one. Run ``python benchmarks/mc_catalog_err.py --help`` to see the available options. """ import argparse import warnings import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from astropy.wcs import WCS from bench_helpers import print_environment from photutils.datasets import make_gwcs from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.utils._optional_deps import HAS_GWCS ARCSEC_PER_RAD = 3600.0 * np.degrees(1) # The pixel error covariance used for the transport check (sigma_x = # 0.2 px, sigma_y = 0.3 px, correlated) and the position at which it # is transported PIX_COV = np.array([[0.04, 0.01], [0.01, 0.09]]) PIX_XY = np.array([12.3, 27.6]) # The centroid kinds checked end to end, as (attribute suffix, label) # pairs. Each kind has x/y pixel values and errors and a sky position # with East/North errors. CENTROID_KINDS = (('centroid', 'isophotal'), ('centroid_win', 'windowed'), ('centroid_quad', 'quadratic')) # The (value, error) flux property pairs checked end to end FLUX_KINDS = (('segment_flux', 'segment_flux_err'), ('kron_flux', 'kron_flux_err')) def make_tan_wcs(cd_arcsec, *, ctype=('RA---TAN', 'DEC--TAN'), crval=(150.0, 30.0), crpix=(20.5, 20.5)): """ Return a TAN WCS with the given CD matrix in arcsec per pixel. Parameters ---------- cd_arcsec : array_like The 2x2 CD matrix in arcsec per pixel. ctype : tuple of str, optional The CTYPE values. crval : tuple of float, optional The CRVAL values in degrees, in the CTYPE order. crpix : tuple of float, optional The CRPIX values. Returns ------- wcs : `~astropy.wcs.WCS` The WCS. """ wcs = WCS(naxis=2) wcs.wcs.ctype = list(ctype) wcs.wcs.crpix = list(crpix) wcs.wcs.crval = list(crval) wcs.wcs.cd = np.asarray(cd_arcsec, dtype=float) / 3600.0 return wcs def make_sip_wcs(shape): """ Return a TAN-SIP WCS with a strong distortion. Parameters ---------- shape : tuple of int The ``(ny, nx)`` image shape. CRPIX is at the image center. Returns ------- wcs : `~astropy.wcs.WCS` The distorted WCS. """ wcs = make_tan_wcs([[-0.1, 0.0], [0.0, 0.1]], crpix=(shape[1] / 2 + 0.5, shape[0] / 2 + 0.5)) wcs.wcs.ctype = ['RA---TAN-SIP', 'DEC--TAN-SIP'] header = wcs.to_header(relax=True) header['A_ORDER'] = header['B_ORDER'] = 2 header['A_2_0'] = 2e-4 header['A_1_1'] = 1e-4 header['B_0_2'] = 2e-4 header['B_1_1'] = -1e-4 return WCS(header) def make_wcs_cases(shape): """ Return the (name, wcs) pairs for the transport check. Parameters ---------- shape : tuple of int The ``(ny, nx)`` image shape. Returns ------- result : list of (str, wcs) tuples The WCS name and instance pairs. """ cos30 = np.cos(np.pi / 6) sin30 = np.sin(np.pi / 6) cases = [ ('rotated TAN', make_tan_wcs( 0.25 * np.array([[-cos30, sin30], [sin30, cos30]]))), ('sheared', make_tan_wcs([[-0.30, 0.08], [0.05, 0.20]])), ('swapped axes', make_tan_wcs([[0.05, 0.20], [-0.30, 0.08]], ctype=('DEC--TAN', 'RA---TAN'), crval=(30.0, 150.0))), ('SIP', make_sip_wcs(shape)), ('near pole', make_tan_wcs([[-0.25, 0.0], [0.0, 0.25]], crval=(10.0, 89.9))), ] if HAS_GWCS: cases.append(('gwcs', make_gwcs(shape))) return cases def tangent_offsets(center, coords): """ Return the East and North offsets of coordinates from a center. The offsets are great-circle quantities in arcsec, independent of the WCS helpers under test. Parameters ---------- center : `~astropy.coordinates.SkyCoord` The reference position. coords : `~astropy.coordinates.SkyCoord` The positions. Returns ------- east, north : `~numpy.ndarray` The offsets in arcsec. """ sep = center.separation(coords).rad pa = center.position_angle(coords).rad return (sep * np.sin(pa) * ARCSEC_PER_RAD, sep * np.cos(pa) * ARCSEC_PER_RAD) def check_transport(*, n_draw=400_000, seed=0): """ Compare the transported sky errors with a Monte Carlo scatter. Parameters ---------- n_draw : int, optional The number of pixel positions drawn per WCS. seed : int, optional The random number generator seed. """ shape = (41, 41) data = np.zeros(shape) data[19:22, 19:22] = 10.0 segm = SegmentationImage((data > 0).astype(int)) rng = np.random.default_rng(seed) print('\n== Covariance transport (reported vs Monte Carlo, arcsec) ==') print(f'{"WCS":>14}{"East":>10}{"MC":>10}{"ratio":>8}' f'{"North":>10}{"MC":>10}{"ratio":>8}') precision = 1.0 / np.sqrt(2.0 * n_draw) worst = 0.0 for name, wcs in make_wcs_cases(shape): cat = SourceCatalog(data, segm, wcs=wcs) err = cat._sky_err_from_cov(PIX_COV[np.newaxis], PIX_XY[np.newaxis]) err = err.to_value(u.arcsec)[0] draws = rng.multivariate_normal(PIX_XY, PIX_COV, size=n_draw) center = wcs.pixel_to_world(*PIX_XY) coords = wcs.pixel_to_world(draws[:, 0], draws[:, 1]) east, north = tangent_offsets(center, coords) mc = np.array([east.std(ddof=1), north.std(ddof=1)]) ratio = mc / err worst = max(worst, np.abs(ratio - 1.0).max()) print(f'{name:>14}{err[0]:10.5f}{mc[0]:10.5f}{ratio[0]:8.4f}' f'{err[1]:10.5f}{mc[1]:10.5f}{ratio[1]:8.4f}') print(f'Monte Carlo precision of a standard deviation: {precision:.4f}. ' f'Worst |ratio - 1|: {worst:.4f} ({worst / precision:.1f} sigma).') def measure(cat): """ Return the measured values and reported errors of one catalog. Parameters ---------- cat : `~photutils.segmentation.SourceCatalog` A single-source catalog built with ``error`` and ``wcs``. Returns ------- row : list of float For each centroid kind, the ``x`` and ``y`` positions, the RA and Dec in degrees, the ``x`` and ``y`` errors, and the East and North errors in arcsec. These are followed by the value and error of each flux kind. """ row = [] for kind, _ in CENTROID_KINDS: skycoord = getattr(cat, f'sky_{kind}')[0] row.extend((getattr(cat, f'x_{kind}')[0], getattr(cat, f'y_{kind}')[0], skycoord.ra.deg, skycoord.dec.deg, getattr(cat, f'x_{kind}_err')[0], getattr(cat, f'y_{kind}_err')[0], getattr(cat, f'sky_{kind}_ra_err')[0].to_value(u.arcsec), getattr(cat, f'sky_{kind}_dec_err')[0].to_value(u.arcsec))) for value, err in FLUX_KINDS: row.extend((getattr(cat, value)[0], getattr(cat, err)[0])) return row def centroid_rows(label, block): """ Return the comparison rows of one centroid kind. Parameters ---------- label : str The centroid kind label. block : 2D `~numpy.ndarray` The eight measurement columns of the kind (see `measure`), one row per realization. Returns ------- rows : list of (str, float, float) tuples The quantity label, the Monte Carlo scatter, and the mean reported error. """ xcen, ycen, ra, dec, xerr, yerr, raerr, decerr = block.T center = SkyCoord(ra.mean() * u.deg, dec.mean() * u.deg) coords = SkyCoord(ra * u.deg, dec * u.deg) east, north = tangent_offsets(center, coords) return [(f'x {label} (pix)', xcen.std(ddof=1), xerr.mean()), (f'y {label} (pix)', ycen.std(ddof=1), yerr.mean()), (f'East {label} (arcsec)', east.std(ddof=1), raerr.mean()), (f'North {label} (arcsec)', north.std(ddof=1), decerr.mean())] def check_end_to_end(*, amplitude=400.0, radius=7.0, n_real=4000, seed=0): """ Compare every reported error with the scatter of the measured values over noise realizations. Parameters ---------- amplitude : float, optional The peak of the Gaussian source in units of the noise sigma. radius : float, optional The radius of the fixed circular segment in pixels. n_real : int, optional The number of noise realizations. seed : int, optional The random number generator seed. """ wcs = make_tan_wcs([[-0.30, 0.08], [0.05, 0.20]]) shape = (41, 41) yy, xx = np.mgrid[:shape[0], :shape[1]] x_true, y_true = 20.3, 19.6 sigma_src = 2.5 r2 = (xx - x_true) ** 2 + (yy - y_true) ** 2 model = amplitude * np.exp(-r2 / (2.0 * sigma_src**2)) footprint = r2 <= radius**2 segm = SegmentationImage(footprint.astype(int)) error = np.ones(shape) edge = amplitude * np.exp(-radius**2 / (2.0 * sigma_src**2)) rng = np.random.default_rng(seed) n_cols = 8 * len(CENTROID_KINDS) + 2 * len(FLUX_KINDS) results = np.empty((n_real, n_cols)) n_negative = 0 for i in range(n_real): data = model + rng.normal(0.0, 1.0, shape) n_negative += np.count_nonzero(data[footprint] < 0) cat = SourceCatalog(data, segm, error=error, wcs=wcs) results[i] = measure(cat) # Realizations with a nonfinite value (e.g., a windowed or # quadratic centroid that fell back or failed) would corrupt the # scatter, so they are excluded and counted finite = np.all(np.isfinite(results), axis=1) n_dropped = n_real - np.count_nonzero(finite) results = results[finite] flux = model[footprint].sum() snr = flux / np.sqrt(footprint.sum()) print('\n== End to end (SourceCatalog on noisy images) ==') print(f'Gaussian sigma {sigma_src} px, segment radius {radius:g} px ' f'({footprint.sum()} px), model at the segment edge ' f'{edge:.1f} sigma, total S/N {snr:.0f}, {n_real} realizations, ' f'{n_negative / n_real:.1f} negative pixels per realization, ' f'{n_dropped} realizations with nonfinite values dropped.') print(f'{"quantity":>26}{"MC scatter":>12}{"reported":>12}{"ratio":>8}') rows = [] for i, (_, label) in enumerate(CENTROID_KINDS): rows.extend(centroid_rows(label, results[:, 8 * i:8 * (i + 1)])) offset = 8 * len(CENTROID_KINDS) for i, (value, _) in enumerate(FLUX_KINDS): measured = results[:, offset + 2 * i] reported = results[:, offset + 2 * i + 1] rows.append((value, measured.std(ddof=1), reported.mean())) for label, mc, reported in rows: print(f'{label:>26}{mc:12.5f}{reported:12.5f}{mc / reported:8.4f}') precision = 1.0 / np.sqrt(2.0 * len(results)) print(f'Monte Carlo precision of a standard deviation: {precision:.4f}.') def main(): """ Run the SourceCatalog error checks. """ parser = argparse.ArgumentParser( description='Monte Carlo verification of the SourceCatalog ' 'error properties.') parser.add_argument('--n-draw', type=int, default=400_000, help='number of pixel positions drawn per WCS ' 'in the transport check ' '(default: %(default)s)') parser.add_argument('--amplitude', type=float, default=400.0, help='peak of the Gaussian source in units of ' 'the noise sigma (default: %(default)s)') parser.add_argument('--radius', type=float, default=7.0, help='radius of the fixed circular segment in ' 'pixels (default: %(default)s)') parser.add_argument('--n-real', type=int, default=4000, help='number of noise realizations in the end ' 'to end check (default: %(default)s)') parser.add_argument('--seed', type=int, default=0, help='random number generator seed ' '(default: %(default)s)') parser.add_argument('--which', default='all', choices=['all', 'transport', 'end-to-end'], help='which check to run (default: %(default)s)') args = parser.parse_args() print_environment() warnings.simplefilter('ignore') if args.which in ('all', 'transport'): check_transport(n_draw=args.n_draw, seed=args.seed) if args.which in ('all', 'end-to-end'): check_end_to_end(amplitude=args.amplitude, radius=args.radius, n_real=args.n_real, seed=args.seed) if __name__ == '__main__': main() astropy-photutils-24be529/benchmarks/validate_catalog_versions.py000077500000000000000000000437561526452531300255340ustar00rootroot00000000000000#!/usr/bin/env python3 # Licensed under a 3-clause BSD style license - see LICENSE.rst """ Validate SourceCatalog results across photutils versions. The ``dump`` command computes every array-valued ``SourceCatalog`` property (plus the ``flux_radius``, ``circular_photometry``, ``kron_photometry``, and default ``to_table`` outputs) for two deterministic scenes and several catalog configurations, using whichever photutils is importable, and writes them to a file. The ``compare`` command loads two such files (e.g., from a released version and a development branch) and reports every entry that differs, grouped by property, along with the properties that exist in only one of the versions. The scenes are the blended Gaussian-pair image used by the ``SourceCatalog`` benchmarks in ``bench_segmentation.py`` (with error, background, mask, and WCS inputs) and a small edge-case scene with close source pairs, sources touching every image edge, masked pixels spanning a close pair, and nonfinite data values. The configurations cover the three ``aperture_mask_method`` values, a bare catalog, a local background, the minimum-circular-radius Kron aperture, sliced and scalar catalogs, and a ``detection_catalog``. The ``dump`` command uses only APIs available in photutils 3.0.0, so it can be run against that release and later. Run it once per photutils installation (e.g., with ``PYTHONPATH`` pointing at an unpacked wheel, from a directory that does not contain a photutils checkout), then compare:: python benchmarks/validate_catalog_versions.py dump old.pkl python benchmarks/validate_catalog_versions.py dump new.pkl python benchmarks/validate_catalog_versions.py compare old.pkl new.pkl The dump files are pickles; only load files that you created. Run ``python benchmarks/validate_catalog_versions.py --help`` to see the available options. """ import argparse import pickle import sys import warnings from collections import defaultdict import astropy.units as u import numpy as np from astropy.convolution import convolve from astropy.coordinates import SkyCoord from astropy.modeling.models import Gaussian2D from astropy.stats import gaussian_fwhm_to_sigma import photutils from photutils.datasets import make_wcs from photutils.segmentation import (SourceCatalog, detect_sources, make_2dgaussian_kernel) def make_bench_scene(*, n_sources=300, spacing=25, fwhm=4.0, offset=6.0, seed=0): """ Build the blended Gaussian-pair scene of the SourceCatalog benchmarks, with error, background, mask, and WCS inputs. This replicates ``bench_segmentation.make_source_image`` and ``make_inputs`` without importing them, so that the dump can run against any photutils installation. Parameters ---------- n_sources : int, optional The total number of Gaussian sources. spacing : int, optional The grid cell size in pixels. fwhm : float, optional The FWHM of the Gaussian sources in pixels. offset : float, optional The separation of the two sources within a cell in pixels. seed : int, optional The random number generator seed. Returns ------- scene : dict The ``data``, ``convolved_data``, ``segm``, ``error``, ``background``, ``mask``, and ``wcs`` inputs. """ n_cells = (n_sources + 1) // 2 n_grid = int(np.ceil(np.sqrt(n_cells))) size = n_grid * spacing rng = np.random.default_rng(seed) data = rng.normal(0.0, 1.0, (size, size)) sigma = fwhm * gaussian_fwhm_to_sigma yy, xx = np.mgrid[0:spacing, 0:spacing] cen = (spacing - 1) / 2.0 half = offset / 2.0 for i in range(n_sources): cell, pair = divmod(i, 2) gy, gx = divmod(cell, n_grid) sign = 1.0 if pair == 0 else -1.0 amplitude = 100.0 if pair == 0 else 60.0 xc = cen + sign * half + rng.uniform(-0.5, 0.5) yc = cen + sign * half + rng.uniform(-0.5, 0.5) model = Gaussian2D(amplitude, xc, yc, sigma, sigma) x0 = gx * spacing y0 = gy * spacing data[y0:y0 + spacing, x0:x0 + spacing] += model(xx, yy) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) segm = detect_sources(convolved_data, 5.0, 10) error = np.full(data.shape, 1.0) error[::7, ::11] = 1.5 background = rng.normal(0.1, 0.02, data.shape) mask = np.zeros(data.shape, dtype=bool) mask[::23, ::17] = True return {'data': data, 'convolved_data': convolved_data, 'segm': segm, 'error': error, 'background': background, 'mask': mask, 'wcs': make_wcs(data.shape)} def make_edge_scene(*, seed=0): """ Build a small scene exercising the edge cases of the catalog computations. The scene contains isolated sources, close/overlapping pairs, sources touching every image edge, masked pixels inside a source and spanning a close pair, and nonfinite data values inside a source. Parameters ---------- seed : int, optional The random number generator seed. Returns ------- scene : dict The ``data``, ``convolved_data``, ``segm``, ``error``, ``background``, ``mask``, and ``wcs`` inputs. """ rng = np.random.default_rng(seed) ny = nx = 151 yy, xx = np.mgrid[0:ny, 0:nx] data = rng.normal(0.0, 0.1, (ny, nx)) positions = [(20, 20), (24, 26), (75, 75), (75, 81), (140, 20), (5, 100), (100, 4), (147, 147), (50, 120), (120, 50), (100, 100)] for i, (xc, yc) in enumerate(positions): amp = 5.0 + i sig = 1.5 + 0.2 * (i % 4) data += amp * np.exp(-((xx - xc) ** 2 + (yy - yc) ** 2) / (2 * sig ** 2)) error = np.full((ny, nx), 0.1) error[::17, ::13] = 0.3 mask = np.zeros((ny, nx), dtype=bool) mask[18:20, 22:24] = True # inside a source of a close pair mask[73:75, 72:84] = True # spans a close pair data[77, 78] = np.nan # nonfinite inside a source data[75, 83] = np.inf segm = detect_sources(data, 1.0, 5) kernel = make_2dgaussian_kernel(2.0, size=5) convolved_data = convolve(data, kernel) return {'data': data, 'convolved_data': convolved_data, 'segm': segm, 'error': error, 'background': np.full(data.shape, 0.05), 'mask': mask, 'wcs': make_wcs(data.shape)} def build_configurations(scene): """ Build the catalog keyword configurations for a scene. Parameters ---------- scene : dict The scene inputs. Returns ------- result : dict The ``SourceCatalog`` keyword arguments keyed by configuration name. """ full = {'convolved_data': scene['convolved_data'], 'error': scene['error'], 'background': scene['background'], 'mask': scene['mask'], 'wcs': scene['wcs']} return { 'full_correct': dict(full), 'full_mask': {**full, 'aperture_mask_method': 'mask'}, 'full_none': {**full, 'aperture_mask_method': 'none'}, 'bare': {}, 'localbkg': {'error': scene['error'], 'local_bkg_width': 6}, 'kron_circ': {'error': scene['error'], 'kron_params': (2.5, 1.4, 6.0)}, } def to_plain(value): """ Convert a property value to plain NumPy data. Parameters ---------- value : object The property value. Returns ------- result : `~numpy.ndarray`, list of `~numpy.ndarray`, or `None` The plain data, or `None` if the value has no array form (e.g., a list of aperture objects). """ if isinstance(value, SkyCoord): return np.column_stack([value.ra.deg, value.dec.deg]) if isinstance(value, u.Quantity): value = np.asarray(value.value) if isinstance(value, np.ndarray): return None if value.dtype == object else value if (isinstance(value, (list, tuple)) and len(value) and isinstance(value[0], np.ndarray)): # A list of per-source arrays (cutouts) return [np.ma.getdata(item) if isinstance(item, np.ma.MaskedArray) else np.asarray(item) for item in value] return None def dump_catalog(catalog, prefix, out): """ Dump the results of a catalog into a dictionary. Parameters ---------- catalog : `~photutils.segmentation.SourceCatalog` The catalog. prefix : str The key prefix (the scene and configuration names). out : dict The dictionary to update, keyed by ``':'``. """ for name in catalog.properties: try: value = getattr(catalog, name) except Exception as exc: # noqa: BLE001 out[f'{prefix}:{name}'] = f'ERROR: {exc!r}' continue plain = to_plain(value) if plain is not None: out[f'{prefix}:{name}'] = plain for fraction in (0.5, 0.3): out[f'{prefix}:flux_radius({fraction})'] = np.asarray( catalog.flux_radius(fraction).value) flux, flux_err = catalog.circular_photometry(5.0) out[f'{prefix}:circular_photometry(5).flux'] = np.asarray(flux) out[f'{prefix}:circular_photometry(5).flux_err'] = np.asarray(flux_err) for kron_params in ((2.5, 1.4), (1.8, 1.0, 2.0)): flux, flux_err = catalog.kron_photometry(kron_params) name = f'kron_photometry({kron_params})' out[f'{prefix}:{name}.flux'] = np.asarray(flux) out[f'{prefix}:{name}.flux_err'] = np.asarray(flux_err) table = catalog.to_table() for column in table.colnames: plain = to_plain(table[column]) if plain is None: plain = np.asarray(table[column]) out[f'{prefix}:to_table.{column}'] = plain def dump(filename): """ Compute the catalog results of the importable photutils and write them to a file. Parameters ---------- filename : str The output file. """ warnings.simplefilter('ignore') out = {'__version__': photutils.__version__} scenes = (('bench', make_bench_scene()), ('edge', make_edge_scene())) for scene_name, scene in scenes: configurations = build_configurations(scene) for config_name, kwargs in configurations.items(): catalog = SourceCatalog(scene['data'], scene['segm'], **kwargs) dump_catalog(catalog, f'{scene_name}/{config_name}', out) catalog = SourceCatalog(scene['data'], scene['segm'], **configurations['full_correct']) dump_catalog(catalog[[0, 2, 3]], f'{scene_name}/slice', out) dump_catalog(catalog[1], f'{scene_name}/scalar', out) detection_catalog = SourceCatalog(scene['convolved_data'], scene['segm'], error=scene['error']) catalog = SourceCatalog(scene['data'], scene['segm'], error=scene['error'], detection_catalog=detection_catalog) dump_catalog(catalog, f'{scene_name}/detcat', out) with open(filename, 'wb') as fh: pickle.dump(out, fh) print(f'photutils {photutils.__version__} ({photutils.__file__}): ' f'{len(out) - 1} entries written to {filename}') def _compare_arrays(value, reference, *, rtol, atol): """ Compare two dumped arrays (see ``compare_values``). Parameters ---------- value, reference : array_like The dumped arrays. rtol, atol : float The tolerances of the comparison. Returns ------- result : tuple The ``(equal, max_abs, max_rel, note)`` tuple of ``compare_values``. """ value = np.asarray(value) reference = np.asarray(reference) if value.shape != reference.shape: return (False, np.nan, np.nan, f'shape {value.shape} versus {reference.shape}') if value.dtype.kind in 'OUSb' or reference.dtype.kind in 'OUSb': equal = np.array_equal(value, reference) return equal, np.nan, np.nan, '' if equal else 'value mismatch' value = value.astype(float) reference = reference.astype(float) if not (np.array_equal(np.isnan(value), np.isnan(reference)) and np.array_equal(np.isinf(value), np.isinf(reference))): return False, np.nan, np.nan, 'NaN/inf pattern mismatch' infinite = np.isinf(reference) if not np.array_equal(value[infinite], reference[infinite]): return False, np.nan, np.nan, 'inf sign mismatch' finite = np.isfinite(reference) diff = np.abs(value[finite] - reference[finite]) if diff.size == 0: return True, 0.0, 0.0, '' scale = np.maximum(np.abs(reference[finite]), 1e-300) equal = bool(np.allclose(value[finite], reference[finite], rtol=rtol, atol=atol)) return equal, float(diff.max()), float((diff / scale).max()), '' def compare_values(value, reference, *, rtol, atol): """ Compare two dumped values. Parameters ---------- value, reference : object The dumped values. rtol, atol : float The tolerances of the comparison. Returns ------- equal : bool Whether the values agree within the tolerances. max_abs, max_rel : float The maximum absolute and relative differences of the finite values (NaN if not applicable). note : str A description of a structural mismatch, or an empty string. """ if isinstance(value, str) or isinstance(reference, str): equal = value == reference return equal, np.nan, np.nan, '' if equal else 'error mismatch' if not (isinstance(value, list) or isinstance(reference, list)): return _compare_arrays(value, reference, rtol=rtol, atol=atol) # Lists of per-source arrays (cutouts) if not (isinstance(value, list) and isinstance(reference, list)): return False, np.nan, np.nan, 'list versus array' if len(value) != len(reference): return (False, np.nan, np.nan, f'length {len(value)} versus {len(reference)}') worst = (True, 0.0, 0.0, '') for item, ref_item in zip(value, reference, strict=True): result = _compare_arrays(item, ref_item, rtol=rtol, atol=atol) if not result[0]: worst = (False, np.nanmax([worst[1], result[1]]), np.nanmax([worst[2], result[2]]), result[3]) return worst def compare(reference_file, new_file, *, rtol, atol): """ Compare two dump files and print the differences. Parameters ---------- reference_file, new_file : str The reference and new dump files. rtol, atol : float The tolerances of the comparison. Returns ------- n_differ : int The number of differing entries. """ with open(reference_file, 'rb') as fh: reference = pickle.load(fh) # noqa: S301 with open(new_file, 'rb') as fh: new = pickle.load(fh) # noqa: S301 ref_version = reference.pop('__version__', 'unknown') new_version = new.pop('__version__', 'unknown') print(f'reference: photutils {ref_version} ({len(reference)} entries)') print(f'new: photutils {new_version} ({len(new)} entries)') ref_keys = set(reference) new_keys = set(new) only_ref = sorted({key.split(':', 1)[1] for key in ref_keys - new_keys}) only_new = sorted({key.split(':', 1)[1] for key in new_keys - ref_keys}) if only_ref: print(f'\nproperties only in the reference: {only_ref}') if only_new: print(f'\nproperties only in the new version: {only_new}') common = sorted(ref_keys & new_keys) same = defaultdict(int) differ = defaultdict(list) for key in common: name = key.split(':', 1)[1] result = compare_values(new[key], reference[key], rtol=rtol, atol=atol) if result[0]: same[name] += 1 else: differ[name].append((key, *result[1:])) print(f'\n== identical within rtol={rtol:g}, atol={atol:g}: ' f'{sum(same.values())} entries, {len(same)} properties ==') n_differ = sum(len(items) for items in differ.values()) print(f'\n== differing: {n_differ} entries, {len(differ)} ' 'properties ==') for name in sorted(differ): items = differ[name] rel = [item[2] for item in items if np.isfinite(item[2])] max_rel = f'{max(rel):.2e}' if rel else 'n/a' notes = sorted({item[3] for item in items} - {''}) print(f' {name:44s} n={len(items):3d} max rel diff ' f'{max_rel:>8s} {notes}') for key, max_abs, max_rel, note in items: print(f' {key:56s} max abs {max_abs:.2e} ' f'max rel {max_rel:.2e} {note}') return n_differ def main(): """ Run the SourceCatalog cross-version validation. """ parser = argparse.ArgumentParser( description='Validate SourceCatalog results across photutils ' 'versions.') subparsers = parser.add_subparsers(dest='command', required=True) dump_parser = subparsers.add_parser( 'dump', help='compute the results of the importable photutils ' 'and write them to a file') dump_parser.add_argument('filename', help='the output file') compare_parser = subparsers.add_parser( 'compare', help='compare two dump files') compare_parser.add_argument('reference', help='the reference dump ' 'file') compare_parser.add_argument('new', help='the new dump file') compare_parser.add_argument('--rtol', type=float, default=1e-10, help='relative tolerance ' '(default: %(default)s)') compare_parser.add_argument('--atol', type=float, default=1e-6, help='absolute tolerance ' '(default: %(default)s)') args = parser.parse_args() if args.command == 'dump': dump(args.filename) else: n_differ = compare(args.reference, args.new, rtol=args.rtol, atol=args.atol) if n_differ: sys.exit(1) if __name__ == '__main__': main() astropy-photutils-24be529/codecov.yml000066400000000000000000000003161526452531300177550ustar00rootroot00000000000000comment: 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-24be529/docs/000077500000000000000000000000001526452531300165405ustar00rootroot00000000000000astropy-photutils-24be529/docs/Makefile000066400000000000000000000012511526452531300201770ustar00rootroot00000000000000# 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-24be529/docs/_static/000077500000000000000000000000001526452531300201665ustar00rootroot00000000000000astropy-photutils-24be529/docs/_static/custom.css000066400000000000000000000153561526452531300222240ustar00rootroot00000000000000/* ========================================================================= CSS Variables ========================================================================= */ :root { --pst-font-size-h1: 2rem; --pst-font-size-h2: 1.6rem; --pst-font-size-h3: 1.35rem; --pst-font-size-h4: 1.15rem; --pst-font-size-h5: 1rem; --pst-font-size-h6: 0.9rem; --sst-accent-color-bright: #ff9a6c; --sst-accent-color-muted: #ff9a6c; } html[data-theme='light'] { --pst-color-surface: #f3f4f5; --pst-color-border: #d1d5da; --pst-color-warning-bg: #f5e4d4; /* Steel blue link/card colors */ --pst-color-link: #2e6da4; --pst-color-link-hover: #1f5080; --pst-color-primary: #2e6da4; --pst-color-primary-highlight: #1f5080; --sd-color-primary-highlight: #1f5080; /* Orange secondary accent */ --pst-color-secondary: #d07530; --pst-color-secondary-highlight: #b85f1f; --pst-color-secondary-text: #ffffff; --pst-color-secondary-highlight-text: #ffffff; --pst-color-inline-code: #913f08; --pst-color-inline-code-links: #2e6da4; --pst-color-link-higher-contrast: #2e6da4; --pst-color-target: #f3cf95; --sst-footer-background-color: #f6f6f6; /* Swap the zebra row colors so the first table row differs from the header background */ --pst-color-table-row-zebra-low-bg: var(--pst-color-on-background); --pst-color-table-row-zebra-high-bg: var(--pst-color-surface); } html[data-theme='dark'] { --pst-color-text-base: #d6d6d6; --pst-color-text-muted: #a8a8a8; --pst-color-danger-bg: #5c2a31; /* Neutral gray borders and backgrounds */ --pst-color-border: #555555; --pst-color-border-muted: #303030; --pst-color-on-background: #282828; --pst-color-table-inner-border: #404040; /* Steel blue link/card colors */ --pst-color-link: #6aaee0; --pst-color-link-hover: #8dbfe7; --pst-color-primary: #6aaee0; --pst-color-primary-highlight: #4a85bc; --sd-color-primary-highlight: #4a85bc; /* Orange secondary accent */ --pst-color-secondary: #e08a42; --pst-color-secondary-highlight: #d07530; --pst-color-secondary--text: #14181e; --pst-color-secondary-highlight-text: #14181e; --pst-color-inline-code: #fbaa74; --pst-color-inline-code-links: #6aaee0; --pst-color-link-higher-contrast: #6aaee0; --pst-color-target: #54311c; --sst-footer-background-color: #282828; /* Swap the zebra row colors so the first table row differs from the header background */ --pst-color-table-row-zebra-low-bg: var(--pst-color-on-background); --pst-color-table-row-zebra-high-bg: var(--pst-color-surface); } /* ========================================================================= Header / Navigation ========================================================================= */ .bd-header a.navbar-brand { color: #ff9a6c !important; } .bd-header ul.navbar-nav > li.nav-item > .nav-link:hover { color: var(--sst-accent-color-bright) !important; } .bd-header ul.navbar-nav > li.nav-item > .nav-link:hover:before { border-bottom-color: var(--sst-accent-color-bright) !important; } /* Theme toggle and GitHub icon hover color */ html .pst-navbar-icon:hover { color: #ff9a6c !important; } /* Vertical sidebar/content separator */ html[data-theme='light'] .bd-sidebar-primary { border-right-width: thin; border-right-color: #c4c4c4; } html[data-theme='dark'] .bd-sidebar-primary { border-right-width: thin; border-right-color: #666666; } /* ========================================================================= Layout ========================================================================= */ .bd-article { padding-top: 0.75rem; } .section { margin-bottom: 1.6rem; } p { margin-bottom: 0.7rem; } div.sidebar-primary-item > a.logo { display: flex; flex-direction: column; } img.index-logo { margin-bottom: 1rem; } /* ========================================================================= Footer ========================================================================= */ html[data-theme='light'] .bd-footer { border-top: 1px solid #c4c4c4; } html[data-theme='dark'] .bd-footer { border-top: 1px solid #666666; } /* ========================================================================= Headings ========================================================================= */ .bd-content h1 { margin-top: 0; } .bd-content h2 { border-bottom: 1px solid var(--pst-color-border); padding-bottom: 0.3rem; } /* ========================================================================= Admonitions (dark mode) ========================================================================= */ /* Muted slate blue for general admonitions (note, important, etc.) */ html[data-theme='dark'] .admonition { border-color: #2e6da4; } html[data-theme='dark'] .admonition > .admonition-title { background-color: #1c4378; } html[data-theme='dark'] .admonition > .admonition-title:after { color: #6aaee0; } /* Orange theming for warning admonitions */ html[data-theme='dark'] .admonition.warning { border-color: #c07030; } html[data-theme='dark'] .admonition.warning > .admonition-title { background-color: #6d4225; } html[data-theme='dark'] .admonition.warning > .admonition-title:after { color: #e08a42; } /* ========================================================================= Typography / Code ========================================================================= */ /* Give dark-mode text selections an explicit translucent steel blue. Without it, the browser's inactive selection color for a dark color scheme is nearly black, so a selection almost vanishes once the window loses focus. */ html[data-theme='dark'] ::selection { background-color: rgba(106, 174, 224, 0.35); } .field-list ul { padding-left: 2em; } code.literal { background-color: var(--pst-color-surface); border: 1px solid var(--pst-color-border); border-radius: 0.25rem; padding: 0.1rem 0.25rem; } /* Brighten the Nord comment, prompt, and number colors in dark mode for contrast against the code block background */ html[data-theme='dark'] .highlight .c, html[data-theme='dark'] .highlight .ch, html[data-theme='dark'] .highlight .cm, html[data-theme='dark'] .highlight .cpf, html[data-theme='dark'] .highlight .c1, html[data-theme='dark'] .highlight .cs, html[data-theme='dark'] .highlight .sd, html[data-theme='dark'] .highlight .gp { color: #929cb0; } html[data-theme='dark'] .highlight .m, html[data-theme='dark'] .highlight .mb, html[data-theme='dark'] .highlight .mf, html[data-theme='dark'] .highlight .mh, html[data-theme='dark'] .highlight .mi, html[data-theme='dark'] .highlight .mo, html[data-theme='dark'] .highlight .il { color: #bd9ab7; } /* Prevent unnecessary vertical scrollbars for MathJax equations */ mjx-container { overflow: visible !important; padding-bottom: 0 !important; } astropy-photutils-24be529/docs/_static/photutils_apple_touch_icon.png000066400000000000000000000431451526452531300263310ustar00rootroot00000000000000‰PNG  IHDR´´˛¯‘eF,IDATxÚí]w|ÕÚ~ÎĖöd“M!=!@Ŋé ĸ€b¯€ŊëĩëĩÜ{ŊčĩwđǍ bCéŊ—HIé=ŲÍļ™9ß3ģ™ŨM§xŋŧŋ_2;gggÎyĪsŪvŪs†PJŅI¤DĖÅŽ@']ēÔ ŽNōKāč$ŋÔ ŽNōKāč$ŋÔ ŽNōKāč$ŋÔ ŽNōKāč$ŋÔ ŽNōKāč$ŋÔ ŽNōKāč$ŋÔ ŽNōKāč$ŋÔ ŽNōKāč$ŋÔ ŽNōKĒ‹]K—_QcŠąØjÍļ‹ÍîäAHY•FƒV§Q…õĄFCXA­úfāģ“?yļ<ģ°ōÄŲōĶ…•eĩE•õeÕf*CƒâA"‚(“1!28!28%ŽKīĈ´¤ČޑϋŨŦöų›`œ_ZŗũøŲY…‡Ngæ•99ÁõŒ#>ŸüADV } 5ę‡öŠŪ+~dZÂā”8–!ģÅ­Ļ˙_ā¨o°˙y8÷9ۏåŸ-Ģ@å2Āŗ¯ũMCDI´„õŌģMÚsÆđ”@ŊæbŗĄĨô˙•u֟wœXŊ'kWFƒã¤RT´"MhęķSwš^ÍNŌãšņ}§Mšô͔˙epØÜ¯ģ˛žßšąųH.įŌŽÖzá"mĐ2h" 42$đ–)nŸ>(ŽKđÅæ“_úßGvaåŠ?ŊņHeÕUFŨ˙š;*āĻŖ ĪÛQ†)Cz!˜1,åīˇNLMčrąYčA˙#āāxáû­™ī¯Úsę\ĨŦ˜zūķ.D›´ Z*;ZĄe° šqRúŗ7_jŧØė”č/ŌÕģŗ^ũf[NQĩØĄ>íĄ>ø@û!r> z­úŠÆ=0{„ŠŊøæę_k÷dŋ˛lKNQĩŦLhD.°! ÷¨ž˛_ˇ¨‹ËŪŋ*8Žœ)}qéæ™Pn€ø@¤I\¤Ø ĘP:œæ^EĖÂąPXÚS“34ė!Ė؁¤ fˆhÕĖÛ÷ĪŧqRúyå°/ũ5ĀņëîŦŋ-ūŖÆlõ*÷ YĢBŠåėēEØ…:ß ‹IĐ^$Gt!Lga*Fp-ôî §æT÷EŅhœ…3—˜q‚}ˆÎų.¤!ōČÕŖ^ē}Ō…dûĨŽ:‹ũąÅVīÎj‰‘īE]iÅ=ÂÖëč~=œōōj֒>[‘ŧ‰įˆÉ}_P\9*58@w0ģøÄŲrŽį!ģq(, ąë~lš†ŪžŨqŨŨ…3ŋ~Īt†\ õ’Į‘3ĨwŋŊ6¯´ŅS•1­)†ÆŌš„5ŗčFvY=Ņ­CīUL˙-čéŖhĢŽ|~ŪeéIjĖļ/ÖūxõžŠZ‹üiŨPąKģĄĀY„ Ŗ:Ā .ĨĒuŦ–širú{^qađqé‚cÉo‡^YļÍé%yK-8uÜ'l~@Ø$—gū3ö[2ČĩĮå>øÔĢSãÃ$G_5*uhj\Yåæ˙x ĢPūėŽ¨ÚŒw°¸×Ŧ¤<îč]Q´[Ëx—ß6māÛ÷Íŧđ¸ÁápōO}ļqÅĻ ´rlM2ū!üOĢÜ%ûI×™qŋ‘4ÁoKũēģcŌē.yl–NŖņ§E•õréđ<Ö?„ÍVĄī|\īYĩ whîȗĪŋũqņc´^TZc™ûō÷+6eâŸx €hÁũ›˙q)˙šg~#;˙röū5¤¯ ^GQn„âU˛íxū5/­ÔiÔO_?Ļņ™ ؂dņēĒÔX5ČîįYe"‚oÃ|šŲø3âQøÎ;?ūeīųî‹K+‡ôdAåM‹VUփˆC…Td‰ëŦņ'“Pņ‰ķËžTūhßb&}Žu€õõeD|´jƒNmĐĒËk, v§ØëÄÛ˛%ĮōĘļÍŨˇkã#AÔ­x(\ulŦ+•¸ĢLÉņh[ęyp?•Š­uÕņ™O×'F™Ļ íyūēãĮΌsķß\]×`C#?šgÜ$zâCîk#•~ĩ…ô|˜Ŋļ˜Kŋ$Äíî&DĪÚcTŸø>‰]bÃÄëJķJjžŪxôĶĩm ÄS˔T™GĨ%\âP+~[†ĀÆ*ē! ;Žûyöuc'ûÃ~cKe#A* å:˙õ7Ŋĩ g\øyę‘KŋėĘzčŖßNžUck†pôcî+1ĘɁųjęģĖDAÆP–šrdʂéĶ“ŖUÖĪ-ûnkĻŲę°Øœ1aÃRã^¸i܄ô¤k^^É ĸi4DRâÃsŠĒ)•( ÆŅlņæĮã eˇ!âŲ"ˆø`Ÿ(ˆPP˜­ŽÛūũÃÆ7îĐkå†v‡Ņ%Ž•[O<žx/Ж‹_ ĖĻßå–Ģ (EĐŨĒ›w3Ũ c(C0o|Ÿ'įŒ3ž+¯{ũģŋí=‘Wæ[ÛϤ/Z8iֈ”Ÿvœ”瀀€Ļw‹Ŋdíwŋ€Đ@jŸƒ#â˙$=å Ä-†ˆLy …ZF„HF^Ų“‹×ŊûĀįŖ_.>8–ũyüé˙nÜCŨGüRīrĐ+ø#īqËY IČ5Ē{rI˜œqi‰oŪ=ĨoRĉŗ/~ĩe͞lŽÄ[hU ÁĄA†ēĒΚ_ZsøL € kXöĨÛ.spÜûĢöȊÉØ€b´)•kD:ÜYúûáɃz\12ĩÃģæ"ƒã‹ GŸ_ē™*2ÎÅŖFÆšš&ŊÃ}#"ã, ŊZ}o •û g |î†1›ķ‰%,ÛxŒũēE>8{Ø°ÔØđ ŊÜm(ĩ;xŗÕą~˙išBzņ–ņ#zĮ˙kųļ’jŗģ<žģŸn/xŒã)ãiĢz@ÂÛ9o†Čc˙62-!,ČĐąŊs1Áņũļ“/,ŨJ)<ĮX3cKOq_ęáPB‚į¨ī/Cā0¯ÎŸpËä~;2 î˙ˇ’*3"BW>;75^ŲpcŅiTˇŋžĒ´Ú"93 O^;úÎËũēëÔ;?ívk™`jũ”.Ķ€…ˆĨĄTc¸ĄŅZ-2DĘĒÍ/|žņƒ‡:Xš\4püļ/įoK6 4Į–7CEˆ<Æ­īNË8 ēM=ŋˆ„@fŲŊuĪ”yãzšáčĶ˙ũS ­šŨôÚ-aAz5)­6ßũʝâŠQ¯yũÎ)ŗG§n<”{˙ûŋš}a5„čˇ]QĀ õæF1p.UVn/ĩFË@ "žÂ˛Y-ŗ|ãá&ö•Öĩûčâ€c{FÁŊī¯ãxĄIÆÁWüÆĶĒœ$ÕŠfeâŨ čÂéæëũéēC/HNJôąĢG„éKĒĖQĄî đ-ĒŦ;”]ōɚ˛‹ŨĒd|˙Ä7îš×%héīGžųlŖ4÷hĀ}*|=…žOŸ&Wž€<‡Ī×ņ§eāĨm[*sŸeÂ2ÕבƒƒQģhׯčā2*6A"”Ō'>Yŋõ…˜9vĀ‘]Xu×;ë8Ū˙˜ōoÁŨÁmĨú)&j‰jœœĄQĄÆįnŗ=ŖāĨ/ˇ QU‘9ŖR÷œ,ŧęīßčÔaAĩš­Ŧk¨ŽŗÉŨ]¤%E>sØ 鉕u ˇŋūķÚŊĸŗJŽ:ž–^FŗÄ‹ŋ"Ö3CåîŽëđˆ"4m>ņNŽ_ŧæĀ›?ėŠŽopW8V-áŋN‡¤tž$Þ`æĀÕĄMCÄCË´ØQƒ[LVÎÂq/օËWdųTáÎLéOËükųÖ9cĶ´jAŽ_øöÚÂĘzCBÆ3[TvL CŠ@=Ņ­eû{ųWéĩņPîIiŨJŖÍļá@Î]—úúŠĢžŪx,ģ°Ęlĩë4Ēø.Ái‰]†ĻÆŽī—¨×ĒęėKÖ\ŧæ@Ayû–![X )Éh ũs…Ģ?]•WŠģ‹ņŅ2Í" č§øv&2Äߜ@äGt$€đ{8ąÂū.ķĶdá.*YYŪ)(¯]ēūā3‡tH]Pp<ŋt로2š†nÕØę)”ŠW``å\°A¨;œS*ģ™tø×ōíÅm“ûOäUŸģķĪ#šë÷åŦŨ{ÚlĩģË HėĪđkovŠ €ŧÎLy“™ʼnlB|ãîm3DžĨŋĪ$2žĄÁUĸŲ›IŖÖ‘OTáÜäÄ/´ˇ?w÷ŊUģī˜>¨C–Ŋ\8p|ųĮņo6gĘøßęąerå~–‘`/߯ęā8^ˆ0xD(âäé+_oũāį}CSc’ĸBôZ•ÍÎW™Oœ-Ī.Ŧ#c’‹@PzpāY~m¤+­°œīeŽßJz¸Ą@ā›ĒÚŧ–¯ŪôŒģOÄŠ‡ˆdn¯ Ā\÷EIÜ×|ö¸{Eo@Qvā\YíĪ;OĖͧũ]vĀq,¯üååÛ=æ<\ ĮüCÄÍĐ:"šŖá´ŪK 98~ûņ‚9ŖR¯=”WRãmŦǎ­Û—yŧû” ]8÷ ŋjÍsĩƒIž—ŊĄ„yÕ] ˆ´"Mēģ!hx?ŠŨÄGČl*ÍÃJļę'tÄmd/€ą$'æ2jôw˙ā§ŨŽ ‘Īaą9ühƒ“”2'\͗ūy1Tú'–Ÿ`bÅī†ņ9Á´Áõ•ôŖgŋØ ā›§gKõΜ ÷ô+w ›îcŠŽ|uDˇŊeOōjX†™28šwŪĶ×îËš÷ũßis äĨđĐ2ŽB7p;9VˆEŸĢÆŧĸšĘ•ûR 4÷Ė8wt¯ØpīĩęķūņÃöŒņb5øqÂÉYÂá)üqqÍu˛…éų/vÆ1§˜‰čǏBĄßĄųĩ™?ŌOĮâ4€ũH˜ŽģФJdW ˜Š“Ëɗ25†>āõŊŧŽÁēS_<¤Õ´Ël8ŋā¨ĒˇMyvEE­ĩ•Œƒ?ˆ0 ŸÚ–LæĨ@.éōĄzŌĪėĀhdŋCĐ'ąKĪØĐnQ!5dĐ:9~ŲÆŖ|~Æš;T83YČ“›œ`f|DÆg21ĘüöŦ …2nZŲR Š‘8ķ3] €‚L!÷BŦüiōc4ęŽãßbmč‹všU ̞øqųŗ×Lo_žØųĮ}üžfoNÛø”ĶØ?ļ~6^8á.˛AŊív’‰ÎBT)ϘSP-¸ZMkb„šn´|Í ĸVßg—Áøƒjđ™1…â^k @V-ŧSlæ*ēx=` és+nöxēĪņ ^† ĀPúhŠŌEâÉĩ—õũčáYíéžķč­üq8ÍŪ3^…m0ōŊ| tˇëīēÛšņ!Į:œtpŽáOáOĩĒzU$` Û˙2`7ĶĶ’Ė 1¨Ē“+¸ly&ĸwCŊÛŠĐĖž´XD†˛“ŨŪ!˜7ŽĪåÃzøúŖŋī?-–į#´Š$ :áM$Ŧߗíäu;į 6÷ʲĕĐÖ ?P‰Ą^î.ö}õ”•Ēaˇ9ˇÎæöĮÉÖ"4Mh1]÷‘¤Ũl÷ŨLw§[,ûæJø@$ļģ¸-s…ņ¨b@k`8H61)ŋ0ũKB ‘DDb‡Xē)'HBĄUĢŪđōĢFĨ֘m„`úО˛néúà aÁCjŨQœŨ­1Ûœ*Ū;žÍxžĀņáęCgËëĐ8‰AÁ„/!DAü*DËHđkš™¯ifö ûđ…=hIOž¸ ęL.cÂU .bBÎ!¤ˆ˜Ž1q'˜ĢāäS3rˆøĻvÁö­ķãtZāŽ čÉ üÉŋķŋūÄ x›™”Cē´v$„QķzX<_BF‰ĪcY,{vî¸~‰¯Ū˙ō—›5jöį—¯˙į“6Î=[Z[ )]-PÜ*ĸÉ€-Gō.9pœĢ¨_üÛON€’VАĪie2ą™L,(ÄõlūÔ5|ƖT?™ˆōŗ;š-rdxrP¸F8p•pøcfܛėd+Qû ž Ŋ{Äy×3ßâŠĀūmŪčqũ__šãĩÛ88ūÁ~›3ēWŲ Պ—Âá&öMØ|4÷IiŅM[čŧ€ãõī÷Ú9Ūģā!hD<‹Û>§ˇüņfhķÉ5— Ö_WMÛ>ąWŦ1¨ž¨KŅŅI–ŊŊi5ø„?'ĶĖĒ[N#ĸ CÄŨ„[…Ũâį%Ė(ąĸÃ{Å=rõˆM‡s˙ŗr§ģY™yeŽŧhb!ąú¯%3CE!‡˛‹íNžÍ“´ŽŒüŠ5{sšI›n9DüYv-ģ+&x1MfųBš–ûMģgZ¯ā–íÍķö”Õ­ØōĮuÅߋIĨ%ŋqīŪÂŪž‹t—lU˙͜DOÄ @=Ņ}K„!ô•Û'Ô78î}wāNŸôŒģ[ā–vÍsCÄÉ ™ųeÚēÉiĮ‡Ī_]šGpqØ#éҎh" ÅŌÖÆŨ=ĘŽJ+á/îžK¤BĮō§ėœ Õh‚ƒ‚"ē„Lé:õÆk7ĪzįÃ, #ĩ}Ã}:’æ4ŪÎ7’Bæâ°øų 0C `ÎčŪéŨŖŪũiwe­<ãwˇP)ĸ‡<|ŦxpøtÛãč ŽŲ%;OzÍ`¸ B|!âq­ZÔęŲ՞í˜8‡ƒãؑE@|BIČ]ļuÔ ŸX`ŧŸ#ƒšÉU%ÜĒzäå†e×Úˇ¸Îą}§*õWõ 9DĐĄqwŲj‘qū#"Té"¨îÖÜz7Ÿđ”sĩ  ažc×Ü#{vëq0¨q`×r+&ÕfĮĐņg•0Ę JĀR:F폰ôĐ7)")Ęôúʝ ō ņč<$‡ˇüe"–Ö´Qrt 8~?˜Wc––ĸ@JRSî ‡´Ød‘~Ž“QĮŠ“ģh,vátšũÜ.ÔMtđ’mÅNuJ%Œō¨‘¨eh+ƒĒžqwÂPq ‚gšŸeFžįđœö¤”|ÄNØJR^åž$ä`!ŒNŠ9…Úlg’KHPãŊ¤Ķs!X$ .ÖĀę=YîP~A˜]XéjicCƒ\ë$,Đø 2~‘z‹¯Ķ"ęĩōŨö,_#_ü4‚;eĸUĸQŨ?<@5o€ijoĶėôĐ+û›´Oęo­$F!‚ų!ë/Ø}ģv"Lt¨ąÃÜ]‚ &ö*ÍCķÕķ1]y’OÂW]ëëՏ‡dŠn'ÉN¨LšœSTuĒ R4ɯŸvdņŨÛßž}åķרUlcCî­vKHPŗņ+s[ Ԑ%ՖŨ'Š•ÆÎI)›5iÔM+aL@ÄŨ!KPĢŲ.&ŊĢRm™šņ¸@É:ļī:ļoZ:?:æÅŅ*=už#!›™Ô¯ŲuĐŸ‰šņ‚”w˛™IA——¸đÅŌū0õæŨSį”ė8^đМa7MėûųúÃōvFQ8ÔØĖ&')ë.8ÖîË(%Ję@_ūŦxŲQU"Ë``|€A§Õj5„ĩJ5°+9]n_›7ä>ûÚ|ąÎųö˙ŌĪ•aƒøh™Ö"á´n4wĒ;cI)Ōwq:0]3ظ–,7ōšËō§e@‘M"ŗU“ÅßĨō#ĩĨ Ōėî&…cû&‚Į Â2x÷žéÕfÛ-‹VU›­ŗG§ÎÚŖ„d]ē–ë˙q/ŋāXŗ˙ŒŒąŪsZqŧdgąąa6,PŖŅ¨5 Ã0*• R‚ŽÛ>ÔMËō€ŲŽŨ‹tŗyÂČ›ˇ´ -Kč͟{ÂņËXî 6 ĶgIø{ÚŠß̆ņ„ČēZÖÛ¤)ˇüi}Āš%î`–KÂķ`dŸx^ {N‚`ō î=bCūh}UŊĀļcgįŽéĨҍÉ5Á*Ž#įÁT’@Ÿ”C"ÎW{"mĨöÚEUæcyō:xŠņÁ,–W’ °•ZÅĒT*FŖIĢ 1ôŅŽĶĒ&Âhũî„BÍ˨iΚÚiuÃĸ˸ y [¤ZņēmŲJë;aÔŦiŗ!â‡ßũF‡ šâųVϧX8<5îXniÅN€[&÷ĢŽˇũ´]Ō;˛‹uU˜Pwl0ŠÖŠ_U ۚĀĪÅĮæŖį=…č#‚\yYuŒ!@M†aYV­VkĩZŊnhWŖ@Të5Å+grûŧųí“æ!ō°}Íßmßģaqœ‰ß5-oČm˛iV"ÍjásžąžHmžEˆxTŠšˆˆDdíD%%ģŸ$‚ĀhĐ$E›. ×ĒÆõK¤ aÁząEe5âļ3ĸ­ã˛F‹]:Ĩ%&9ĶÖ;Ú Ž-Į š%ÉąŦ78´Z­VĢIŠ0DÕkՃÅ+G:OyÞžL#Dā2îøCöĩâįL6nVĀ—<•9ķ_dÁ;ˇîđĪĨęq⡊BŅköå2†ūŖ đ–^Pr\߄qũâû&JBxƐîŪģmĪŠâ‡ķödėųj-œAÔFëËIPĶS3íģ¤2pÜÖ(@ts%]LZ€ƒ“ō´„‹Ĩ5.pDČ €ĻķŨ#CŅ&j;8JįV¸¸§ą<ŋčÁŲyZZīåDr8b†aƤcōYŠÍ |™gœ^kIX² Qą`†eˆQ¯‰ 7F…ôÜ q­ü]×NŽž8Û÷FáÁ†Ë‡vŋ|h÷Ę:+xu%n}Ø^Ti.¯mpđœĀS'/đ*m­Üî|÷@jīJ+ů“bÔĢà š%Õĸœa™F)nÔk@Ŧ$ŗ@2-ũ¯ V#@a Ęųî"Ô.(8ōJë\/ŖP[RQ+’!Î,"§Ü–Ë9N‡ÃaŗŲApã#HĪV2’nt/jõž <‡­ĩØĪUÔSā S­?Xļîđ~tZÜč>qÃRbÜéų…õ[l>zöpvŅļz)@÷åž’ â9uŲĻ|w˙G’LKÅʗāZb$Ö€A§–øK°‘Ļe5Éā nƒ#Ōkŧx@ÄŗĐ U‡/882 Üųg<'2”î×ôäĀĒĀĮņ1|UVšv‚Ũáp8ėv;˲„Ņ U‘*WBOūāgK_ņËF^æ<`pÃąo ž(¨\ōۑÁ=ĸž{ö*ŋė>ũāGÄ į˛4”PCLjāmņÂ;/…Bā7u÷”› Bƒô*j­D<žÃ˛ @ē˜”×6ˆ×÷CĻnebJǏĮ TR´ŠÍ/üjģAzĒ Ú‹Š\Ņe¨%á1Îy,§ÂQmļ‹ā°Ųlvģã8Q~°šü^WϤr(Éà õôe6Ēú‰Å3ûhĨXxđtŠŲęđÛžˇwŸ}ŊxåŸĒ4J˜ķīîqîļ%Oiw[Qڗ×6°9x^†q5&Ėhļ:ĖV‡xˇ~Ž%w'™¨Æ§6øIŠ isˇšeuMgųēi“ĻŋøáJû.Ģ“9gļŲínp8NžįA°9a¤Ė Ņzų^Ŋāëpņh*å$ @Cš,‹°D Č8[ÉņÂæcgÅÛ=āX7†?€‚,Ռw=ĻŊ™ˆM¸ģ=ÉŗËb"ŋBuĒ-VņēZKã2ĢÉ1!9ÅÕâĪĐŽčęAtUōڈDHrlčEG^i‹ŖŠ~`c—ūĸ!ŠŨÎĶąBųŪ|k­Ų&"CĮqĪ×Ú¸`*)ūQΡ Äääũ<1…3?ûSũĸá|öe—œĢ¨ˇ9ø.´î? KŗIi‹?¨‡qYEÍCDа…înŖĘD‰åz€ÕΉ×ek\Cp0ģ@rLčé"Q<“$Z)îÁj‡*“ņøņHߤČ6wqmŌ‚ wfb3‹Z ŲđƒęäAÎl:ŋaũŋÔ7*¨­ĶˆĄRJ)Įq vžŌ* Ą’…XÁ5žÆ†ˆhwėR§.ŌÍyÚö€îBÉ7æ7혈’_{ŗûÂW˜âOĢ\s.ĮØŽ/蝓ΤĘ!Ōžä†ĄĐ€‹$í´čžQˆa —~q,¯lx/iGŧŨ'Š"‚ Áڜĸ*ņ&ƒø<ņĢ#$ž7äoÂą—öKŠ@[Š’ŖĸÎjwze 4ĩ–dŠ^Š8]mÛęŦŪ|ÚZTŨĐ`ĩÚlv›Ínw8O–488Ō‡“$įI6^!čę~CÄ}ļD?å9͜ë­‰BŲđ˛ÍņGŋÆgš‘ņ§Ēī [‰F4ō› %É[ÔZC$I(ŊĐj e•¨X€¨ČŖ]™Ō´K]ƒ„^ ]€Fq’îŌ)bŒ¤…ąÁā]|—āļbŖ­ā(¯ĩ6žõ-Ũ œ­’L§Í+ĘĖüęŒÚâjkÅjąŲĢęmģķ­ĨCœŌæķĮ]6Ŧŋhcŗ†ČríØŠÁ_Ŗė^?čĻSlė}†… î5įĪZ īB?†H,$ã=Ÿ„ģ["Ž.VBđŸ‡ķķËjl<”‚=ĸĘ)GÂ`!Oŧė ĶÕÛ÷<Č †¤ÆļįŨÄmT+åĩVHæp‹Ä¯ōnĀė÷jß0ŨžoŊuΟĨÃ8žfh‚>@Ë:g=SÉõäŠúpųxÂėÖ¤*E›Ķ2đHŽÉc"XHmCY=ĩõ)‘›ōíGؤ|ļ‹ô΃+vĐņ;D¸rûĘHĢ9¨Ŧŗ3ęΐ/|ˇõÄãW˙jã1éŨ"Ëj,%Uf:pŊhąx‡LĸĖsGĶáãኹm‡F›ÁQen4­[¸ģÆīÚAëuƒ§Úöxĩūŗ;˜Đã=Šęx† ÁI‚p¯õWņĘíę´riZ¤É­\L @ģk˜‰îOMŋíŦjLdėÆâ|%Nx‡’:x+"78JI°ģ%•u ƒĨ‰†!WŽčYRe>UL@ŌģGĘ)/ÂįŠ‹KI°üĩTŠûĖČÃ{ÅĩmT+f+§Āĸæĩžxs @OŸÔŊ3ēáP­U¨jŦáJëŽËíŌ+–ŋŌMđVĮļ"Ī—Ö4´ÜČīHCˆjÅĸRi×v€ ķl€ônâû1ȌĄÉ=bC?^shˇhS„)ā@–$-Æ RÂú6Ļgif^Ŗ^Ûŋ{Û]´YrXėN_#_d¨ŋ}Ķ@i%´0ø‘¯j Ö Áōaí{;5Ŋ÷¨SģņÅŗlģÄk˙ĐÜĒí E)áĄXüžįKņÅiÃMŠâ×wēšĶö|wya¤+Ŋ¯” vßģ´Æ’WZ;:-Ž|‹PŖūśĮ”ÕX–ũycû&Øvŧ@”?ã\āØÂĻē›éoŸ÷a\˙ŽíŲJm–6įl5ĩî9SŨõŽā‡Å9:ʑņ¨å‡Ģl;Å]ÜΰŅŧY"đ– įđ)W؀…Rbs8ũų>C7 PŠ­XŪáG –ÛĒköžîß-ráŒôOŊ<ÂđĐGŦ ŖĶâĢÍļŒŧr¨KŠPíl–Kņ0!=ŠmëĻ6‚CĘéjŊ‘O9 éyečK›4ũ=ßjƒę7˜ž.o[”Âé*įxtž#ß_´ąÍZÆ-9ʘ šģûá¯OUŋp㘁ÉQ/}ĩmûņ¨Xfdī¸íĮ PŒįOО+ƒ‰•˛ŊŊ6y-ĨO !$ļ­sŨÔKšŋŠķČlČ=χcųōöŊq|EcØĄîŗ[Ķ‹ĸ™€öh^Ē†ĢžÍæˆ(ÍiÂmíÚL¤,ë $ÚĒbuëė͟ûv|ŋ„ĖŗgËjōI‹2h?˜+ĒēFÂ¤ļ|ÃģáŊâڜãĶ!āđ6ō]I7Іˆ‚‘_¨ę˛„ŊÜĮ%hyr/d â^c ‚—†ö€ˆWÜđŽeĨ´•î.ÕQiiŧ Žíx\îŽŨÁ­ÛÆÍ*:sX˛ÃÉo<”+ ‚1œ´RA48š- nCäŠíÚõ\¤6Ē5ËøŠ_—Xnĩøõ.Dķm0D¨ā!ÜŸ”Ô |ĘĨBqŠ~Ģ  8âę@÷´ŗĢR^z*–2¨Û–cgë­Ĝ0˜X ŨĮvķl™;DéĻf™CÛû> ´­JÆIˆø7DšuweŒëāĩ$ŧ€Ļ-8Ī3eC„!„‘ßŖ#Á-6'Qy^Mä0uPˇ@Ũ/ģŗÅ/gqÅë~gûŠĢŽŊZϏ$â˛ūI]‚;āÕåmT+bÂŖˇķ4ëē´ ŧd˛ĄÜ(~ŨåáB}o.ŋ_.ԊÛ~XˆŽŒ˜rUQ™l|Ôfwˇ%†ˆV­rđ˛Õ§¤9w×Ŋ‹—¨)49IyĮÔ~uÖßöåDa:DŧâWÕ@ŊęŠ7Ŋ”ëõ—uĀ;ŧĐfpõoE-3äävYv^ŒZw‡ĢųŨųâ+­ģ&9&sEūjEANĢĸ7hŽŌŽČe"!ˇV/†ú‰ģ7cˆ„ëí֙1 ÚQcįO8ƒ7ŠĶ”ö™QˆģË%‡guŊGÂSû;9~ŲĻ Q¸uĘZ6Ũ=}ØôĢwrÃe}:äŊĄh38"‚õ˛j)Ē—V@Ä_Út_û¤yÅļŨDöä´*ö4SȆÕ€`Á#Töā “šb×ģxéxįŅq5Į~ŌŽøwĀŧjØB#ß ČDH ­zŪērĒãמ:ā ŗB=z‘~v=ô>ÂŌŖ™DfG˜ ģzØ/ģOo;^ •šÜŨ”ø°9ŖR~Ø~˛ŦĻ 8§ōŌxøU5ĐˁW Đ¨ŲÛ&÷īd´‘ÁEīĪRš†ˆĸ!2Ō‘ņZíâp×Ŧ€=š^?jGmÖö¯aĄ }"Ô_æ82Įļc¨ķ”ø¨9öŖ™îQĨ¸ęÖ:C$Ī˙ŧūŨ0ęŊįK…[‡sY7>\âž&TjĻÅĩæĪ@í vnöČ'¸ĀáúÁßomsp¯ŋG,šÆß+UAŒģ؞W*´ ņ8kDĪ6/DđĨ6Ɵ@Ŋ:$P§hĶ+xXč …€GÜ}žu˒ę7ÜČØĄI›ú÷[LOŦŌĒaŨ õ TW3ÆuŖn2=qĩéšŨę^ba„Pŗ´îšö-qwŊüōPZŋÄüûT=2‡,ؙR^ą™ĸ×fqePģſܑynĘĀ$ų*ŗ†÷Õ'îŖÕË\ÛBŪîÜ"~øI5„#LKŧ6Bȝ3´njģrJŠ j䀪•"­qwoĩnxŠî 1uĒžč ž{~ČãꤖGDŽĒ“n1=ū„q™č°TxÕüōÖMMšģŽēĘ!ō uu¤P hį>pņqzĶĢ5ķ^ôÂķúĘZ_.˙zûvšģëUE+#Ix5¸õr#L†ŊbŨ#ĄwBøĸųãOœ­X˛îˆøIãĪ âĨw{-ՌUŒˆČĄ!–OÔ­wBøĨލ 9(Pûę=§ĢęmĪß82P¯!„ôMŠøėŅV;7˙íßlŌ>änŗxũŸĒ´ŗ$ōĘú!„āĄŲC;íGjlˆCŊ€˙HņhŸ]=¸s¯Ô}&"ã”:ūϰg XŸŊH3‘æąQכžĘbc0 ‹ĖŸuãK”‹^DJãΊq”2&øí°ØđĀĮįŅĒŲčĐĀ[&ö Ô}Ē›$ Ū|ûŊ“ŠÂŌę2;⍎:ŸųbKĪØĐ ˙ēvų“ŗVŊ0GĢfįŋũ[Ą+a;Œš¯pJûĮ-Uõ×L/ˆL˜ä^ÜQÔvp¤u “s×ũŠe†ˆTDxŖæ#ø.6|aČãՌŅOÜū6Ŗ"jjÔU2Á Lˆ‹ũ ÔūVũb‚/XBX†ŅiTzĘ¨ícē,A K¨ģöŲķö-[_ģ~á4iÅԋ7ŽÚ÷Î-ĢßŧÛ(‰ņiŨõ‰‘ÁAZŊFĨSŗ*†0 q÷ŠēHZKÖČŊõ?krŠkē–oƜūÜw‡sJŨíšÁš] '€&r›Ē—Ÿ•^›3“'įčXd =oŠq!Zëā„6D#"ˇZÖ÷āÎpBõˆéžrqÃIā/…ĶO´Ņ QŊw÷†Ŋåu%}8øîe5‹X*ôæÎŪdûsŠn’W(I vOĢƒĢąØ÷:ëīT{wŲŽņũFöЉ [Rģ#ŗpËáœ÷,’Éŧ5§Ļ„q{ܞB&TÜ7 N¨<Ä&`{ÆšmĮĪųzõFj]āøS<ũB3ÎßBĶ™ˆķÆöJ‰ CGSÛÁĄf™> a‡Î”ûŲZO‚ˆŋÔN¯C Đp—YZhôßĀˏjēûFDtÕ°žQcĶb{ĘLZŽōË돜)˙yONuŊ͋qĶ%é āŪËûŋ÷ëáƒĒŸëĻ,°ŽpïßkĮ4mĶ Yl ! Ũí'wīZģ/7uh΍žą!ŊÂĻJ|ęš![ū}Íŗ×ĶkXšø]ĩûôÆ#g,˜Ú7ˍđ~Ā•bšI0_kÛâQ#%Û´œ Ų¯N@@Yž ļŠ:kqĩųx~/QBõ ÖâÅëÔy°ūÚ Ûåúöá š0ÉŖiÍBįâWh§Ú‰īŽ< iv\9D”gNíĮđ”H((8‡-0DčÜÉ­_0ŨF4rÆE–>2åųë†Eš ŠĒ,ŋė9ķų™Ë6ŸÜu˛˜¨FÅŪ<Ą×wO_×ÅčæĨxå›=NNĐkTˇLėM+Ņ~ޟ"~{}›’ ę}ū–~ļ(Øp9?מ2׹3÷Āūœ=;î°ũņKí?bø* Dûn†ûįŠî|+ŊÜĩŸßDDäiۏâë›Î2áߨGhÂŪŋHŠ2Ũ9#ũ| hßģė9^ûĖĒÆ÷ĮČîD=O䟨Oa_gΊōØĄų^͏3IthĀōĮ§‰°Øu˛øŊ_:S.ŋ]Dū+Ō¯Ũ@Vaõu¯­m°sn;äÕ[GĪ™\ZĶ0öɕ áÍÛ+Ķüg…ŧtR/=ˆúTßuŧ×ēú‘†Uū8 €<xįZÍ ŋÍ@a öCĩĒÁ˜d|)Įĩ"R~Õîø-ˆŸī4ÜõģĘ# čō'¯Õ§]ëš vIË\–ã?Ú¨?hŒˆČÆÖHģôîĒíē~ul€{ččÔŨ;!ŌdāxáŸ+÷Ũņî‡ÎTČ+@€ō:ëķ_īzö˝”ĸglČsîîēy"M†îŅÁŠf;´ŌtöhgFc=üÅ>ÔĪ|)āFĢ‚„G1Ūeŧ˙7Í`Ÿæ{ ËFģ_%mG6™;"ķčĨĄÔüī†¯Ä ļĒz˙ŽJ÷z–ĸ×6olīķ‡ ´ŌIéM,j…b*Н–l—Ō$wéŌäbyáÔ´ž1&_ļ{Ųæ“n†ĒX&>ÜčV´„āĮ]§—m>āĒÉĻ­û9ûN—pŧ`P˛´‚c§ZĮĮ)9ķå˙ŧ´Ė×úË&›ūųĄūōcĒÄ:ĩŠReÚ§îą(āę !˙ܤî_{K)ÖąA“.õ¨}#ŗØA ˙QÃâ.´@Ņ?i¸Š%iۑ!Ī]?į“Ú›`<25ʨ×ÔKŒ4•Âéæļ¯ģÛ͕ĸq\ŨÍÍדᖠŊü´+į§Ũ9îōéƒ˙uËHq§*ŗíëM'˙û{†ƒã˙ģ!ãúqŠ:5;y@ÂwÛŗÅë­vžŧÖĐEœFޏ‘ĖyxŌPlt,eCŪ ˜ũZXiŽkpøhĄfŪ­Ō {˛áG-œIBéĮîī5#įÕāŪļ~6”“*üœū†é-O"ƒü.ŧ[tĮø`÷08?Ô^ÉĄUŗĶ'([tMh™ÜĐRg$/­%ĪSEģŋžq\ŠNÍÚÜģĢ¸ /œøúŖŨ|…ęŧ"}í‹WĻƔT7ä•ÕHˆđØŨW\š¨Ÿ™ĮJÛâÄđ•,¨b´QŪxļ¨ĘlˇÚ9˙ÂŌīZ’Z°B+ŊVūyëĘ~|>@ē %Ëëߚîō6>ÖMũU=D^yerķÄ´ËúwíH (Qd…ĖŪŨ“ĘŒ…w7€ÚD­g n†Žîā÷ÃgËÄeŒ:úŲk‡únøųÃSRbC,V'€@ZžÚŠĶ¨Øœŧ(”k™1æÍB€ĮÆįžĸlˆÔ7ØŠ,ÆÕĒŪÕ_QN‚Šõ‡úEÛęžųŊîĨAŧ$Ќøî*/W>î.€žąĄį[ĄˆÔāčŌ;>TIjø[rÆéŠôĒŅēGZh.%6ĀÎÅîģÎÜÕ ĨEUŪ/- Ôžĩ`\÷č`¸ļr'D™ JĒĨ•#”06÷ļt‚ûũV ІĨ(Uráq’!RCî ŧW´mYąBĨ8*ĩĶž0Ü"Hų“1žÆ€ŊFõū}“Eğoę˜|˛[/KõāMk´Œ‘ēJ ‡{€D‡D֞(¨v˙ŦO|(€Ė‚ĒG?ŨVouzÕ!)*(@§ĻT“HŠqĄb.t^i­ëŅTëJęôôA$<7­exJ)%͏ĖŊ rHÕí*ãŗ{T=Eé%€ėR§\o|ė5ũlęę ™IN|!ōĪÛÆõlĮ6_­ĸŽāÔņī¯9vŽŌė?ņÛû—­j&z1J­Ļ\ `53zF×vŨõ6§‹-ĘōچŖų ßßøÖü1ŅĄŪ‘ÁĪūČ8]\ë>”ž ÁÎí;-íĶ,XÄL¤ŅøNXū÷Ĩ‚;[Ząe~3Ũ&ųiUô ÆĮģĐēĄž‚1VŖ,"âiąû,ŧģmrß9Ŗ:`ĩR Šc$Л'ô„÷˜k‘!â$š26Düܕ/vß°‘¯Ž‹Ëkm"LË̘ųƝ+ļeYėŌfN^xũĮoütĐL1ę57ŒO°åø9‡“ĩL"/íęWĆķ„õŋᝲ!ÂS ?Ųyķ&y9”ÅÆV‘ oĮŪķgr-34%æŲë:~ęĩ ę0Õ5wD÷¯6eĢ4+/jEScëŒ*VtXԜg24ŨœĢŪī”n,­n‡Rvq €”ؐذĀÂJŗÍÁŊ˛bī+ßî7hÕ9Å5îmÂPB¨ãēx´[>!¤?—+~ÎQEģꎸáXNeޏģŋZ´ņy‹–DxMßú™ŊôÁ}“;*­ŧ…ÔaSŗĖ]Sûø3ōy¤$RęRįF؎‹ŠĒ-N^Đ+>Ô=ļ~;˜oą9Y†<<+ŊņvgËëOœĢrpdC}Ū¨ˇLHđĶŽœ“įĒŨå#\ŅØšĪøÉ!j!âŨĖ˜äŠî.΀@ŨįNŌwTgĩ:‰3wM‰5)ų~Ä­tÜĨ“^}2ÆvÄH­„čžėRS$¸^ßāXž5 ŒÁ‰ÍęĪÆSîJ†0ķ'÷yáúaNVŋūã~ˇäĻ–Qv ;´ižņƒfĶĖ!Ō-ƒORę4ę%Mëeęđžo–: Cžšz`Ŗ7æÍ((Cä¨&ųœ*€–:Ž´l{cŨÁŗú'†‹ĒøķÖŨ}ĒĀSĶ>}pÂāä"„z5;e`Š'§=6{ ːĸ*Ë]īoŦˇ:]C’ĖmØ*Î~aŖ3U‰Mī3Ŗ—ÁęÛ"ī‘ā˙Üŋ!â ËŧÉíÚŊŠÍÔŽYYEúû7ûVíɕøŸö”/¨[õ`íˇJذ™ŅoÚĄ6ę5ë^ŧ"ȠؚQtīĮ›Ũ×õš7î5˛—d1TÔYĪ”Ô9y!H¯I‰3i¤W8c{fŅ3_îǍkp?B/Ø×—?..wxŨxŨgĶåÕö7íŠTL}ĒīŨĸ&ĘķÕÔ§eđæÂ 3‡uĮE"öÅ_ėØ;ėÖeõūŧģ{GšÆáæOËČŌ$Ė3˙ĄĨÎ@jåÁîĶõv8y'/ŒęŨ5˜_^Ÿ]T#^īpōĢ÷įU›í)ąĻ@Ú UĮ†&t1Fš âb“įĒ_ûņĀ›?j°sōg>P˙ãĮ1ĩ$ā Ķ=NĪũI/CČ̎ģjdķĐé-ĨŽ—v*š÷㭔ú[Š™ ę~~°vQßņbĻ&IÍ2+Ÿ˜–lwōwŧģņH^…üG CF¤DõM ëaTąL}ƒãtqížėŌŦÂī¤Š~Žœ¯+_39oX8͕MáŨ|ĒT(~ĄXęģđŽ…ÂRQԈwdōęmcį^†"pxeåvå4-~ŊĨĻÜw%Ous(PEŪųRøÍcSBĩ vvũq¤ĀëNîO>bšą L¨]Qūb _ K?7ėež°˛Ÿ)i?ZĻ (t”–QąĖ Æ_>´Ûųč—VŅųō›Ÿ˜žŌĒ9-'Q=ö€jņ\é‡å¯ …•æGūģŨbw´Ē7įžy|jk3M‚yqåë"2lDķ7ĶŊ"2d?ķŽRßׂvnEÔŧ–ŅiUÜ;ņR@Î8´jöíųŖ‚4n†*CÄĶ9ŠI|%lNęã8ŗ´ėĨhŽō@NųMon(¨03„ˆĄņž^@WņUÅ+ŠÎ|äĶüluœˇOŲ$D.¤!lĐ~ņČ´‰éį}.ž…ÔņŠ›ŒzuhĶúCŌöیƒĸģ{J“hc4#lĮ„ uŗļUE¯Ū—[Vkũí`ž§Øx'ų'ņlĒm·UoDš’E^žá;ÃĪĀŧĻ(Zķ €V KyÍã—ũmFŸŽšØĩtžl7ũ¸;÷åo÷Ë ZdÁ]_ŋūÉę/ׅ[tß5]—ĨŽ÷gÁyŨ)ÅyöáēclGÄ2Ė?Mˇ~k˜čĮ2ĄŪUiĨ!ĸėîĸ†Č äČ÷îžčNWģDčŧƒĀûkēá„gYķm;ü¯ĘL|Ŋë;˛C×˙—€1ÛtˌNū ÷ëXÛĄY–íŖėGŨĢĪǘ 'CīŨŠM“]ßÔĄņ¤C Ō“üēą)/\?B­ē ķ&-Ą Jņī}ģũ´ī7ĘquáĒžŦY:šaükž°'ԉ§Õņ…Ēpqë‹j‹ãʒįRų,õxEĐoú‹L7U0Áž!Ļö@„PvlũēģP‚ˆVÅ>ũđycRpIŌ…JņÚO‡žŲό492īŽû~ˆ-ŗUOÜ­íķQĐ\ŲėZ3înSZĘPh§ģ›ôΝ—õŠŋ@™;m  ”â??Yž%ĢmĄ¤TGŪLËļ Ö}q\YO9̊ܤü‹aĖ)uŧםܟÚ‘Õ2W īūâ# Z5.aēpāiųÖė7~>Ęģ^$ëI-˛UcøŠŪŽœŽÎâpŽÆŊišĘ”¯ŠÎP'Ģ›ļUŨŸZĻ^ÚĨŌ ƒúųk‡Ī~ŅfLZNÖ8ûŌˇ×#_1îŪôŅã~ž}ŨÁ†H âî“Ō^ēqąĪĖh]p8YXķøįģ Ģ,í1ō!˙¨nŸņ,h¯–é¤lÎāĢū ÃMĒÍöį–īŨy˛¸ sZ^š??-Ã2äÆņŠÍ æÁ˙…čĸĨøq÷™˙ü|ÄæāŅZã_ąëáG” •é8wwd¯č'æNģt]’&čb‚C¤œ’ē—W8š'ž°ų|j~ ŽŽ2DRãBŸ3hT¯˜‹ËŪöĐÅJąæ@ū?­ąØÅķV…’.5C¤{tđÂ)i3‡vcßIģŋ]āŠĘl_ü{æģrÅM:Öi—–A͞ŖņĒ> awLî3u`CūÚ°é‡HgËÍū–ąáČ9At /ļ!Ō-Ã26-æļ‰Ŋ‡öŧ8™Āį‰.9pˆtŽŌ˛|köģrI"Ō!ˍģjD÷kF%Į‡/6Ī:ž.QpˆTQgûiwî{rKĒ:FËĀDZŠe´*vLŸ˜™CĮ§Å]‚ŗŠE—48DēãdÉēC[ŽYl΋hˆč4ėČÔ¨‰ũã'ô‹7ę˙bA‹6Đ_n˛;ųí'JļemĪ,Ѝˇų|ž ‘îQÁCzDŒí;,%ŌŊŠĐ˙ú+ÃM”âTQ́œōÃg*åVTÔšŌ1†ˆNÃöŠ MëÚ/1|prDx˙/é/ /Ǎŗe×fÕä•ÕVZŠĒĖ%ÕV'ī5ņĢ 5Ët ÖG™ ąĄI‘A]#ŒŨĸ‚#‚ūę!ŠĄ˙p(R]ƒŖÆâ¨ļØ­ŽįŠÅ.íæcÔŠ !FŊÚhĐéÕÁí˙DHâŧĐ˙,8:Šũô?ë†uRûŠä—:ÁŅI~Šä—:ÁŅI~Šä—:ÁŅI~Šä—:ÁŅI~Šä—:ÁŅI~Šä—:ÁŅI~Šä—:ÁŅI~Šä—:ÁŅI~Šä—ū“ca•b‰,IENDŽB`‚astropy-photutils-24be529/docs/_static/photutils_favicon.svg000066400000000000000000000203601526452531300244500ustar00rootroot00000000000000 astropy-photutils-24be529/docs/_static/photutils_logo.ico000066400000000000000000000353561526452531300237510ustar00rootroot00000000000000 h6  ¨ž00 ¨%F(  Ļo,Ģr0pŠp/¯¨n.ȧm-ŧĻl,•Ŗl+Nŋ€@Ŧr0eŠp0ņ}gc˙tdm˙Ÿk4˙Ĩk+˙Ŗj*˙ĸi)˙ĸh(Ũžg'TĒo/l¨o/˙9\Ŋ˙Ypˇ˙`jž˙'VÍ˙˜h6˙Ąh(˙ g'˙Ÿf&˙e%˙œc$›ĒUĒq+$¨n-÷‚gZ˙XwĘ˙ÛÄŦ˙ßË´˙„Œ˛˙Z]…˙Ÿf&˙že%˙œd$˙Ža5˙Š_9˙˜a!Ą¨m.ŒĻl,˙|d_˙Zpĩ˙âŌž˙åÕÄ˙’™ģ˙V\‰˙u_\˙:[ļ˙a0˙,Tŋ˙$SË˙ƒ\<˙”\dĻk+ĐŖj*˙Ÿh,˙,VÅ˙di’˙kez˙%WÔ˙a9˙F\Ŗ˙&_ä˙y_Q˙+\Ō˙•‰Ž˙:UĻ˙“[î‰b ĸi)ėĄh(˙k_n˙bP˙c]v˙\[~˙a8˙™a"˙”`&˙€^D˙–^˙%SĮ˙ˇ’i˙&XŅ˙ŽY˙Wl g'áx`[˙9`Ä˙-VÂ˙›c#˙‡_>˙3ZĀ˙bZn˙–^˙•]˙“\˙%SČ˙áÔÃ˙ToŊ˙nUM˙ŽUļŸe%šFYž˙˛ŧØ˙(VÉ˙™a!˙\\€˙o‡Ë˙+YË˙”\˙’[˙‘Z˙(Rŋ˙áÚÖ˙z~ž˙QSy˙ŒSߜc$qGXš˙Hhž˙NXŒ˙—_˙Œ]+˙O[˙tYK˙’Z˙_Vh˙5U­˙BR“˙‘¨˙‡ww˙>Q•˙‰Rę™f"‡]:ōQX‡˙](˙”]˙Z9˙X7˙‘Y˙zW:˙Uė˙VØ˙jRN˙FhĀ˙dS˙9Pœ˙†PΖ]k•]˙”\˙DU•˙9^Ā˙>bĀ˙ QĖ˙NS˙^Tb˙€T)˙‰R˙$QÂ˙fi˙BOˆ˙„O‹˙“[Ļ‘Z˙3SŽ˙“ƒ˙ŌŊĨ˙ÛÍŋ˙[lĻ˙8Q ˙ˆR˙‡P˙pO9˙*Pŗ˙pM2øƒL%€@X~V1˙"RĮ˙^lŖ˙Ŗz˙ŧĸ„˙QĪ˙…P˙…N˙„M˙‚L˙KmŒSYˆSáWQj˙-Pą˙'SĀ˙QOn˙„N˙ƒL˙Kō€K f™f‡PS…O›ƒNƒMɂKą€J r…N āĀ€€€Āđø( @ Ļo,Ģp/RĒq.o¨o.Ѝp/™Šm,…§m,nĨn-OŸp0ŋ€@Ģr1^Ēq0ÅĒp/˙Šo.˙¨o.˙¨n-˙§n-˙§m,˙Ļl,˙Ĩl+˙Ĩk+û¤k)Åĸj)vŸp0Ģq.=Ģq/×Ēp/˙Šo/˙Šo.˙¨n.˙§n-˙§m-˙Ļm,˙Ĩl+˙Ĩk+˙¤k*˙¤j*˙Ŗj)˙ĸi)˙ĸh(ņĄh(‡ĸh#Ģq1sĒp/üŠp/˙Šo.˙”kG˙gb˙^`‰˙zec˙¤l.˙Ĩl+˙¤k+˙¤j*˙Ŗj*˙ĸi)˙ĸi(˙Ąh(˙ g'˙ g'˙Ÿf&īŸe'bĢo/|Ēp/˙Šo.˙§o0˙N^ž˙UŨ˙Q^˜˙\_‰˙6Yē˙ VÖ˙ŒgJ˙Ŗj*˙Ŗi)˙ĸi)˙Ąh(˙Ąh(˙ g'˙Ÿf'˙Ÿf&˙že&˙e%˙œd$¨•j+ Ģp/RŠo/˙Šo.˙¨n-˙S^—˙?_š˙āĐŋ˙§o/˙´ƒN˙Ģw;˙yda˙TŪ˙˜h6˙Ąh(˙Ąh(˙ g'˙ g'˙Ÿf&˙že&˙že%˙d%˙œd$˙œc$˙›b#ŝb' Žk6Šo.í¨n.˙§n-˙œk;˙Tâ˙Đē¤˙š[˙äĶĀ˙͎˜˙ßĖĩ˙ÉŠ„˙S]‘˙U]Ž˙ g'˙ g'˙Ÿf&˙žf&˙že%˙d%˙d$˙œc$˙›c#˙›b#˙šb"˙˜a"•j+ ¨m.Œ¨n-˙§m-˙Ļm,˙vdi˙A[Ģ˙âĪģ˙Ŋ’d˙Ņĩ•˙đįŨ˙ĮĨ~˙ŪĘ´˙q^˙+VÅ˙Ÿf&˙Ÿf&˙že%˙e%˙d$˙œc$˙›c#˙›b#˙ƒ_C˙v]T˙™a!˙˜`!˙˜_ š€€Ēj+ §m-ņĻm,˙Ļl,˙Ĩl+˙qbn˙F\¤˙ÚÃĒ˙ˇ‹X˙õđé˙čÚÉ˙āΚ˙Įρ˙Ąˆv˙'VĘ˙že&˙že%˙œd%˙‡eK˙—c*˙›b#˙šb"˙‰_:˙Rß˙&TĮ˙MYŽ˙—_ ˙–^˙—]s§m+TĻl,˙Ĩl+˙Ĩk+˙¤k*˙gD˙UØ˙Ŧ{D˙æ×Æ˙Ξ‘˙ā͸˙Ŋ•h˙ÚÅŦ˙ghŠ˙FZž˙d%˙œd$˙5W´˙*]Ø˙SÜ˙`2˙™a"˙VYƒ˙PXŠ˙“^&˙SÕ˙w[M˙•]˙•]ø–^.Ļl,•Ĩk+˙¤k+˙¤j*˙Ŗj)˙ĸi)˙8Xĩ˙Y^ˆ˙ĸj+˙ŗ†S˙¨t:˙žf&˙Ŗ‡q˙Sß˙‰a@˙œc$˙™g.˙Ué˙ėáÕ˙MmÁ˙l`n˙˜a"˙;V¨˙vjt˙Āžy˙n[]˙-Tģ˙”\˙”\˙“ZŊŖk+äj*˙Ŗj*˙Ŗi)˙ĸi)˙Ąh(˙˜f4˙(VÉ˙8X˛˙p`g˙yaW˙V[ˆ˙Sá˙o]d˙›c#˙›b#˙šb"˙1Vš˙/_Ô˙SŨ˙‹^2˙—_ ˙6U¯˙”Ž˙ĸq9˙Áĸ}˙SŲ˙„Z4˙’[˙’Z˙[5Ŗi*ÜŖi)˙ĸi)˙Ąh(˙Ąh(˙ g'˙Ÿg'˙žf(˙k^m˙?XŠ˙7W˛˙QZ˙a9˙›b#˙šb"˙ša"˙™a!˙—`#˙€bL˙_*˙—^˙–^˙5T¯˙qZT˙”\˙¤€˙PZ˙NU†˙‘Z˙‘Y˙YŦĸi)ßĸh(˙Ąh(˙ g'˙ˆdG˙CY¤˙p`f˙že%˙d%˙œd$˙œc$˙›c#˙›b#˙›b$˙™a"˙™`!˙˜`!˙—_ ˙—` ˙–^˙•]˙•]˙7UŦ˙nYW˙˛‹^˙ރV˙ˆ~˙"RĘ˙Y˙X˙Xú•U Ąh(Πg'˙ g'˙”d4˙Sā˙jf€˙SÜ˙Œb<˙œc$˙›c#˙›b#˙šb"˙‰_9˙udh˙_,˙—_ ˙—_ ˙–^˙–^˙•]˙”\˙”\˙FV’˙\Vp˙Ũ†˙Ėą”˙ÃĻ„˙Qā˙‰X"˙W˙ŽW˙‹UK g'šŸf'˙Ÿf&˙O[’˙Wfž˙ËĘĘ˙I`¨˙f]o˙›b#˙šb"˙ša"˙s]Y˙SŨ˙EhÅ˙RÜ˙Š]1˙–^˙•]˙”]˙”\˙“[˙“[˙YVu˙IU˙ÄĻ…˙îåÜ˙ÅŠˆ˙2SŽ˙iUU˙V˙V˙VŸe&’že&˙že%˙TĶ˙Ÿ–•˙įįį˙Qd¤˙_[w˙ša"˙™b"˙›c&˙7\Ŋ˙š—Ē˙ôíæ˙_vē˙^a†˙–_!˙”\˙“\˙“[˙’Z˙‘Z˙kWW˙5TĢ˙ÃĨ„˙úøö˙ČŦŽ˙QS{˙KS„˙ŒU˙ŒU˙‹TˇŸf&Pd%˙œd$˙Så˙Äē°˙āāā˙9Zļ˙q]\˙™`!˙˜`!˙—_ ˙DWš˙OY˙Ëē¯˙,SŊ˙hY_˙”\˙“[˙’[˙’Z˙‘Y˙Y˙†X*˙QÚ˙ÃĨ„˙đéá˙ÁŖ˙dU\˙9RĄ˙‹T˙‹T˙ŠSĮ™f"œc$û›c#˙Så˙šŽĄ˙¯§Ą˙Rã˙“`(˙˜_ ˙—_ ˙–^˙],˙6UŽ˙Wß˙KW˙“[˙’[˙’Z˙‘Z˙Y˙‚X0˙†W'˙W˙QÔ˙ޜ‘˙Ũˆ˙ÆĒ‹˙uS;˙&QĀ˙ŠS˙‰S˙‰Rڜb#ąšb"˙-Už˙kg}˙†Q ‰Q{‡PʆPũ†O˙…O˙„N˙„M˙ƒM˙ƒL˙‚L˙K˙K ȁJ a™3€M „NU…Ns‚M‹ƒKœKŽ‚L r‚JV€N ˙€?˙ü˙ø˙đ˙ā?Ā€€€€ĀĀāāđøüū˙€˙ā?˙ü˙(0` $€€§p0 Ģq/F¨p0[Ēp.i§p-kŠm/b¨n-OĻo,.Ēj+ Žt.Ēq/lĒp/˛Ēp/ņŠp/˙Šo.˙Šo.˙¨n.˙¨n-˙§n-˙§m-˙Ļm,˙Ļl,ũĻl,ͤk,˜Ĩk*OĒq Ēp00Ēq/ŦĒp/úĒp/˙Šp/˙Šo.˙Šo.˙¨n.˙¨n-˙§n-˙§m-˙Ļm,˙Ļm,˙Ļl,˙Ĩl+˙Ĩk+˙¤k+˙¤k*˙Ŗj*ķŖj+Ąĸg'4Ļo,Ģq/§Ēp0ūĒp/˙Ēp/˙Šo/˙Šo.˙¨o.˙¨n-˙§n-˙§m-˙§m,˙Ļm,˙Ļl,˙Ĩl+˙Ĩk+˙¤k+˙¤k*˙¤j*˙Ŗj*˙Ŗj)˙ĸi)˙ĸi)˙ h'ÂŖi*=Ģq0OĢq0ëĒp/˙Ēp/˙Šo/˙Šo.˙¨o.˙¨n.˙¨n-˙§m-˙§m-˙Ļm,˙Ļl,˙Ĩl+˙Ĩl+˙Ĩk+˙¤k*˙¤j*˙Ŗj*˙Ŗj)˙ĸi)˙ĸi)˙ĸh(˙Ąh(˙Ąh(˙ g'˙ g&ŗŸh( Ģp0vĒp/ũĒp/˙Šp/˙Šo.˙Šo.˙Ĩm1˙}gc˙Z`Ž˙P^›˙aa„˙hK˙Ļl,˙Ĩl+˙Ĩk+˙¤k+˙¤k*˙Ŗj*˙Ŗj)˙Ŗi)˙ĸi)˙ĸi(˙Ąh(˙Ąh(˙ g'˙ g'˙ g'˙Ÿf&˙ f&ķžf&l˙Šp/}Ēp/˙Ēp/˙Šo.˙Šo.˙¨n.˙sdo˙Tä˙!UÖ˙I\Ŗ˙S_•˙>[°˙Sė˙0XÁ˙gF˙¤k*˙¤j*˙Ŗj*˙Ŗi)˙ĸi)˙ĸi)˙Ąh(˙Ąh(˙Ąh'˙ g'˙ g'˙Ÿf&˙Ÿf&˙žf&˙že%˙že%˙žd%˛Ÿ` Ģo/gĒp/˙Šo/˙Šo.˙¨o.˙¨n-˙\`Œ˙Tæ˙…su˙ˇˆT˙Ļl,˙Ĩl+˙Ĩk+˙Ÿj1˙V^˙Sė˙…eQ˙Ŗj)˙ĸi)˙ĸi)˙Ąh(˙Ąh(˙Ąh(˙ g'˙ g'˙Ÿf'˙Ÿf&˙Ÿf&˙že%˙že%˙e%˙d$˙œd$˙œc$ڞb&"Ēq--Šo/öŠo.˙¨o.˙¨n.˙¨n-˙{fd˙Té˙xX˙îãÖ˙ļˆS˙Ĩl+˙¯{B˙ą€H˙Ĩl-˙Ŗj*˙hav˙Sä˙œh1˙ĸh(˙Ąh(˙Ąh(˙ g'˙ g'˙Ÿg'˙Ÿf&˙Ÿf&˙že&˙že%˙e%˙d%˙œd$˙œc$˙œc#˙›c#˙›b#į˜b!/™f3Šo.ÖŠo.˙¨n.˙¨n-˙§n-˙§m-˙!VÕ˙obr˙ÛÊ˙ž”e˙­x>˙čŲÉ˙âŅŧ˙ßĘ´˙íáÔ˙ž•g˙ĸi)˙5Xš˙W]‹˙Ąh(˙ g'˙ g'˙ g'˙Ÿf&˙Ÿf&˙že&˙že%˙e%˙d%˙d$˙œd$˙œc$˙›c#˙›b#˙šb#˙šb"˙›a!ė›d)Šo.n¨o.˙¨n-˙§n-˙§m-˙§m,˙iJ˙Sė˙Ļo1˙íâÕ˙¤k+˙ā͡˙ģ`˙Ĩm.˙Ĩm.˙¨s7˙čÚĘ˙´†S˙wb`˙ UÔ˙ g'˙ g'˙Ÿf&˙Ÿf&˙žf&˙že%˙že%˙d%˙d$˙œd$˙œc$˙›c#˙›b#˙›b#˙šb"˙ša"˙™a"˙™a!˙˜`"ä”c!Ÿ` ¨n.ë¨n-˙§m-˙§m,˙Ļm,˙Ļl,˙qcn˙0XÁ˙­x=˙äĶĀ˙¤j*˙ėāĶ˙ÅĄx˙ũüû˙ú÷ô˙˛ƒN˙ĩ‡U˙ÛÄŦ˙˜f2˙Pø˙žf(˙Ÿf&˙Ÿf&˙že&˙že%˙f&˙d$˙œd$˙œc$˙œc#˙›c#˙›b#˙šb"˙^I˙n]`˙–a%˙™`!˙˜`!˙˜_ ˙—_ ͟` ¨n-a§n-˙§m-˙Ļm,˙Ļl,˙Ĩl,˙Ĩl+˙mas˙4Yģ˙Ĩl-˙îã×˙¤l,˙ßĖļ˙ūūū˙ūūũ˙Ôē˙Đ´”˙Ąi*˙ęŨĪ˙œg,˙Qü˙e)˙že&˙že%˙e%˙d%˙ g)˙œd%˙œc$˙›c#˙›b#˙šb"˙šb"˙o\`˙Rë˙"SÍ˙$TË˙_+˙—_ ˙—_ ˙—^˙•^˜§m-ÂĻm,˙Ļl,˙Ļl,˙Ĩl+˙Ĩk+˙¤k+˙€eY˙TÚ˙Ŗj*˙Đŗ’˙͎™˙Ļo0˙Đ´”˙˛„Q˙Õŧ ˙šo˙Ļq5˙åÕÃ˙„bK˙Rå˙že%˙e%˙d%˙d$˙…aD˙H^§˙H\Ÿ˙ˆ`>˙šb#˙šb"˙ša"˙™a"˙!TĪ˙h[h˙—`!˙9VĢ˙5Uą˙—_˙–^˙–^˙•]˙”^LĒq+Ļm,ûĻl,˙Ĩl+˙Ĩk+˙¤k+˙¤k*˙¤j*˙Ąj-˙ Rí˙‡dM˙ĸj*˙Ķšœ˙éÛĖ˙ÕģŸ˙åÕÃ˙Ų¨˙Ÿg'˙Ä y˙β‘˙MZ•˙AYĨ˙d%˙d$˙œd$˙a9˙ Rė˙Zn°˙Rg­˙Rč˙`1˙™a"˙™a!˙_2˙Qô˙”_%˙—_ ˙‘^)˙Râ˙nZ[˙•]˙•]˙•]˙”\å™U"Ļl+SĨl+˙Ĩl+˙Ĩk+˙¤k*˙¤j*˙Ŗj*˙Ŗj)˙ĸi)˙X^‹˙*VĮ˙Ÿh+˙Ąh(˙§r7˙ŗ…R˙ĢzB˙Ÿf&˙Ÿf&˙ÉŠ†˙ŒjQ˙ Qđ˙‡aB˙œd$˙d&˙f'˙ee†˙CiĖ˙îåÚ˙ėáÕ˙7dŌ˙mfy˙šc%˙™a#˙t\T˙SŌ˙™b$˙Ŧ€N˙–_ ˙aYn˙RĐ˙•]˙”\˙”\˙“\˙”Z…Ĩk+ƒĨk+˙¤k*˙¤j*˙Ŗj*˙Ŗj)˙Ŗi)˙ĸi)˙ĸi(˙žg+˙+WÆ˙7X´˙šf/˙ g'˙Ÿf&˙Ÿf&˙že&˙že%˙‚bK˙Sã˙]\}˙œd$˙œc$˙›c#˙›b#˙n]a˙TÕ˙Ãǐ˙š˙Rß˙u\S˙˜` ˙—_ ˙eZj˙/Tš˙Íŗ•˙š”k˙Сš˙“^"˙Râ˙rZQ˙”\˙“[˙“[˙’[ôY!¤l+­¤k*˙Ŗj*˙Ŗj*˙Ŗi)˙ĸi)˙ĸi)˙Ąh(˙Ąh(˙Ąg'˙šf/˙:Xą˙Rį˙S[Ž˙xaZ˙aL˙o_f˙>XĒ˙ Qđ˙a\v˙œc$˙›c#˙›b#˙›b#˙šb"˙™a#˙:WŦ˙ Sō˙ Rō˙BVŸ˙˜_ ˙—_ ˙—_˙\Yu˙7V°˙ŧ„˙•]˙ŗŠ]˙´a˙PW…˙6T­˙“[˙’[˙’Z˙’Z˙‘[‡Ŗj*ÃŖj*˙Ŗj)˙ĸi)˙ĸi)˙Ąh(˙Ąh(˙Ąh(˙ g'˙ g'˙Ÿf'˙Ÿf&˙{bV˙AYĨ˙"UĐ˙SŪ˙(UĮ˙QZ˙Ža8˙›c#˙›c#˙›b#˙šb"˙ša"˙™a"˙™a!˙™`!˙fA˙a8˙—_ ˙—_ ˙–^˙–^˙\Yv˙6U­˙•]˙”\˙”\˙Ņšž˙„[4˙ Pō˙Z$˙’Z˙‘Z˙‘Y˙YëŸ` Ŗk)Õĸi)˙ĸi)˙ĸh(˙Ąh(˙Ąh(˙ g'˙ g'˙Ÿg'˙Ÿf&˙Ÿf&˙že&˙že%˙e%˙d%˙œd$˙œc$˙œc#˙›c#˙›b#˙šc#˙ša"˙™a"˙™a!˙™`!˙˜`!˙˜` ˙˜`"˙—_ ˙–^˙–^˙–^˙•]˙bYl˙0Tļ˙”\˙“\˙“[˙ˇ‘g˙Ē~M˙#SČ˙dWc˙‘Y˙‘Y˙Y˙X˙ŽWXĸi)Ôĸi(˙Ąh(˙Ąh(˙ g'˙ g'˙Ÿg)˙M[•˙Sß˙JZš˙œe(˙e%˙d%˙d$˙œd$˙œc$˙›c#˙›b#˙šb#˙šb"˙œc%˙™a!˙™`!˙˜`!˙˜` ˙—_ ˙—_ ˙—^˙–^˙–^˙•]˙•]˙”]˙jY^˙'SÄ˙“[˙¯…V˙˞•˙™e*˙ÆĒŠ˙KU‰˙?S˙Y˙X˙X˙X˙ŽXŠ h(ÍĄh(˙Ąh'˙ g'˙ g'˙Ÿf&˙>YĢ˙)UĮ˙~aQ˙ UĶ˙f]r˙d$˙œd$˙œc$˙›c#˙›b#˙›b#˙šb"˙ša"˙™c$˙ l0˙˜`!˙˜`!˙˜_ ˙—_ ˙—_˙–^˙–^˙•]˙•]˙•]˙”\˙”\˙{YA˙Qā˙’[˙Í´˜˙ĻxD˙ŋŸ{˙˞•˙nWQ˙RÔ˙X˙X˙W˙ŽW˙ŽVō€U+Ąh(¸ g'˙ g'˙Ÿf'˙Ÿf&˙yaZ˙Ræ˙Ÿ_˙ĩ­¤˙ncq˙'UÉ˙œc$˙œc#˙›c#˙›b#˙šb"˙ša"˙™a"˙h\h˙.WÂ˙,ZÍ˙\Yw˙–_!˙—_ ˙–^˙–^˙–^˙•]˙•]˙”\˙”\˙“\˙“[˙Ž[#˙Pú˙Z˙Ëą”˙”^"˙įÜÎ˙Č­Ž˙]%˙Pô˙…W)˙ŽW˙ŽW˙V˙V˙ŒW5Ÿf'˜Ÿg'˙Ÿf&˙Ÿf&˙že&˙2Wē˙_`ƒ˙ÃÁž˙ÎÎÎ˙zm˙Ré˙›c#˙›b#˙šb"˙šb"˙ša"˙™a!˙b[p˙Rá˙tpˆ˙†…œ˙SÔ˙NX‹˙–^˙–^˙•]˙•]˙”\˙”\˙”\˙“[˙“[˙’[˙’Z˙Qé˙X7˙Ëą“˙¨|J˙˙ūū˙ē—o˙¤wD˙$RÄ˙cU^˙ŽV˙V˙V˙ŒU˙‹VnŸf'pŸf&˙že&˙že%˙˜e,˙ Qķ˙—€m˙ÛÛÛ˙ÜÜÜ˙™m˙Qô˙šb#˙šb"˙ša"˙™a"˙™`!˙˜`!˙SĶ˙weh˙ÚĮ°˙åØČ˙nU˙ Qî˙”]˙•]˙”]˙”\˙”\˙“[˙“[˙’[˙’Z˙’Z˙‘Y˙"RĘ˙kVT˙Ëą”˙Áĸ˙˙˙˙˙´Že˙´f˙FSŽ˙CS‘˙V˙ŒU˙ŒU˙ŒT˙‹S– h';že%˙že%˙d%˙€aL˙SÜ˙¯œ†˙ííí˙ŨŨŨ˙ˆvo˙Sā˙ša"˙™a"˙™a!˙™a#˙šb$˙ĸo5˙Vį˙ŽĨĻ˙ņęâ˙öņë˙Ęļ¤˙Rų˙žo:˙–`"˙•^ ˙“\˙“[˙’[˙’Z˙’Z˙‘Z˙‘Y˙Y˙8SĻ˙QU}˙Ëą”˙Ā ~˙˙˙˙˙ĨxG˙ē™s˙\Th˙+Qˇ˙ŒU˙ŒU˙‹T˙‹T˙‰T´Ÿ` e%õd$˙œd$˙q]a˙(UĮ˙ˇĒœ˙ééé˙ĐĐĐ˙ca~˙/Vģ˙™a!˙™`!˙˜`!˙˜` ˙—_ ˙—_ ˙@VŸ˙4T˛˙ŗk˙Į̌˙CVš˙-Tē˙”\˙“\˙“[˙“[˙’Z˙’Z˙‘Z˙‘Y˙Y˙Y˙X˙ZUn˙.Rĩ˙ÃĻ…˙އY˙úøö˙•d*˙Ā ~˙rTB˙QÜ˙‹T˙‹T˙ŠS˙ŠS˙ŠSƜd$°œc$˙œc$˙p^_˙'UČ˙˛¤”˙ÕÕÕ˙ĩ¨š˙1U¸˙^Zw˙˜`!˙˜` ˙—_ ˙—_ ˙—^˙–^˙Ž]*˙0Tļ˙Qô˙ Sķ˙&SÅ˙†[2˙“[˙“[˙’[˙’Z˙‘Z˙‘Y˙‘Y˙Y˙X˙X˙W˙|V6˙ Pî˙Š€S˙¯ˆ\˙Ņģĸ˙n:˙¸–o˙‚S&˙P÷˙‹T˙ŠS˙ŠS˙‰R˙‰RЛd$\›c#˙›b#˙†`A˙Ré˙¤‡f˙Ŋģ¸˙ld˙ Qė˙Ž_/˙˜_ ˙—_ ˙—_˙–^˙–^˙•]˙•]˙•]˙‹^0˙‘jF˙“[˙“[˙’[˙’Z˙’Z˙‘Z˙‘Y˙Y˙€W3˙MUƒ˙=T˙mVO˙ŽW˙ŽV˙Pã˙x[F˙ĮŦŽ˙ļ“l˙ØÅ°˙ši3˙‹T˙Qõ˙S$˙‰S˙‰R˙‰R˙ˆQŌŽU ›b$íšb"˙ša"˙ TĐ˙b\r˙‡bC˙RÛ˙ZY{˙—_ ˙—_ ˙–^˙–^˙•]˙•]˙•]˙”\˙”\˙“\˙•_!˙“[˙’Z˙’Z˙‘Z˙‘Y˙Y˙X˙NUƒ˙ Pī˙<\ĩ˙Ua•˙ Qđ˙‹V˙V˙4RŠ˙QSy˙ќ˙‹U˙‹U˙ŠS˙ŠS˙Qá˙uR7˙‰R˙ˆQ˙ˆQ˙‡QĀ›a#Œ™a"˙™a!˙y]P˙Rč˙Qö˙SY„˙—_ ˙–^˙–^˙–^˙•]˙•]˙”\˙”\˙“\˙“[˙“[˙“[˙’Z˙‘Z˙‘Y˙Y˙Y˙X˙GS˙S×˙ŖĄŦ˙âââ˙~€•˙Qå˙ŒU˙ŒU˙\Tg˙%QÁ˙˛Že˙Ĩ|L˙ŠS˙ŠS˙‰R˙PŲ˙oR?˙ˆQ˙ˆQ˙‡P˙‡PĒ™f™`!õ˜`!˙˜` ˙—_ ˙—_ ˙—^˙–^˙–^˙•]˙•]˙”]˙”\˙”\˙“[˙“[˙’[˙’Z˙‘Z˙‘Y˙‘Y˙Y˙X˙X˙W3˙ Pī˙ĻŖ§˙æææ˙‹ĸ˙Qč˙_Tb˙ŒU˙‹T˙…S ˙Qō˙W0˙Íĩ›˙‰R˙‰R˙ˆR˙PÔ˙lQD˙‡Q˙‡P˙†P˙…P€™a"‰˜_ ˙—_ ˙—_˙–^˙–^˙•]˙•]˙•]˙”\˙”\˙“[˙“[˙’[˙’Z˙’Z˙‘Z˙‘Y˙Y˙X˙X˙X˙W˙qUH˙Qß˙cl—˙7[¸˙ Pî˙]Td˙ŒU˙‹T˙‹T˙ŠS˙2QĒ˙IRƒ˙ąd˙Ģ„X˙ŠT˙]#˙PÜ˙pP<˙†P˙†O˙†O˙„OQ’[$—_ ä–^˙–^˙–^˙•]˙•]˙”\˙”\˙“\˙“[˙|Z@˙:T¤˙QÛ˙ Qđ˙ Qî˙RØ˙2S¯˙UUx˙‚X/˙W˙ŽW˙ŽW˙ŒV˙RSx˙2R­˙LR˙‚U(˙‹T˙‹T˙ŠS˙ŠS˙ŠS˙lRF˙Pä˙…R˙ÁĨ…˙θŸ˙™k7˙ Pë˙wP-˙†O˙…O˙…Nü†Q •]R–^˙•]˙•]˙”\˙”\˙”\˙“[˙“[˙sYL˙ Qí˙IVŒ˙uWG˙˜qL˙ޘ†˙ĄšŖ˙[Zz˙1Są˙Qō˙+S¸˙nTM˙V˙U˙ŒU˙ŒU˙‹T˙‹T˙‹T˙ŠS˙ŠS˙‰R˙‰R˙ˆR˙$PÁ˙NQw˙‡P˙‡P˙~P ˙Pö˙„O˙…O˙…N˙ƒNĀ•]Ÿ”]˙”\˙”\˙“[˙“[˙’[˙’Z˙-S¸˙YUt˙‘Y˙ĩe˙Ā }˙šh0˙‘[˙ŽW˙ŽW˙ˆU"˙QS{˙Pæ˙/Rą˙T,˙‹T˙‹T˙‹T˙ŠS˙ŠS˙‰R˙‰R˙‰R˙ˆQ˙ˆQ˙mP@˙ Pë˙vP.˙†O˙^OX˙ PÆ˙…N˙„N˙„M˙„N_™f ”\Ō“\˙“[˙’[˙’Z˙’Z˙‘Z˙#RČ˙jVW˙X˙ɯ‘˙X˙W˙žžz˙Č­˙Ëą•˙Į̌˙Ÿq=˙†T!˙@S•˙Qį˙lTI˙ŠS˙ŠS˙‰S˙‰R˙‰R˙ˆQ˙ˆQ˙‡Q˙‡P˙†P˙GP‚˙PÔ˙jOA˙ PÆ˙OOp˙„N˙ƒM˙ƒMę€@•X“[â’Z˙’Z˙‘Z˙‘Y˙Y˙IU‹˙4SŦ˙X˙´f˙°‰^˙ŽW˙ÖÁĒ˙ˇ”m˙ļ“l˙œm8˙Ŋžz˙ɰ“˙\!˙_R^˙ Qí˙lSH˙‰R˙‰R˙ˆR˙ˆQ˙‡Q˙‡P˙‡P˙†P˙†O˙…O˙JNz˙OÚ˙6Ož˙ƒM˙ƒM˙ƒL˙‚MnY.‘Zī‘Y˙‘Y˙Y˙X˙†W'˙Qâ˙ZTm˙ŽW˙Áĸ˙ˇ•n˙ŋ ~˙õņė˙ūūū˙üúø˙ÂĨ„˙—e.˙ŌŊĨ˙\!˙]R`˙Qã˙ƒR˙ˆQ˙ˆQ˙‡P˙‡P˙†P˙†O˙…O˙…O˙…N˙„N˙„M˙ƒM˙ƒM˙‚L˙‚L֙3‘W,YåX˙X˙X˙W˙tVC˙ Qę˙STw˙V˙¨~N˙Ė´˜˙ģšu˙§~O˙¤yH˙Ė´™˙ŠS˙–g0˙ĀŖ‚˙‰R˙*Qˇ˙RQq˙‡Q˙‡P˙†P˙†O˙†O˙…O˙…N˙„N˙„M˙ƒM˙ƒM˙ƒL˙‚L˙‚Lø€M2ŽU$XۏW˙ŽW˙ŽV˙V˙wV<˙QØ˙+Qˇ˙zT3˙‹T˙ši3˙Ŧ†Z˙ąŒb˙\"˙‰R˙ŒV˙ĮŽ‘˙ˆQ˙KP|˙9Pœ˙‡P˙†P˙†O˙…O˙…N˙„N˙„N˙„M˙ƒM˙ƒL˙‚L˙‚L˙K˙‚Lh’[ŽVŽV˙V˙U˙ŒU˙‡U˙ERŒ˙ Pđ˙5QĨ˙mSF˙ŠS˙‰R˙‰R˙¸—r˙Íļœ˙˜j4˙„P˙%Pŋ˙OPs˙†O˙…O˙…N˙„N˙„N˙„M˙ƒM˙ƒM˙‚L˙‚L˙‚K˙K˙J˙ŽVsUõŒU˙‹T˙‹T˙‹T˙S(˙FR‰˙Qá˙Qá˙3Q§˙LP|˙`\r˙WPf˙CP‰˙PÚ˙)Pˇ˙‚O˙…O˙…N˙„N˙„M˙ƒM˙ƒM˙‚L˙‚L˙‚K˙K˙Kũ€J xŠW#‹Tš‹T˙ŠS˙ŠS˙‰S˙‰R˙ˆR˙nPA˙NPx˙9Pœ˙-P°˙.PŽ˙?P˙dON˙„O˙…N˙„N˙„N˙ƒM˙ƒM˙ƒL˙‚L˙‚L˙K˙K˙€I ė€I P‰TCŠSȉR˙‰R˙ˆQ˙ˆQ˙‡Q˙‡P˙‡P˙†P˙†O˙…O˙…N˙„N˙„N˙„M˙ƒM˙ƒL˙‚L˙‚L˙K˙K˙K ū€JĒ€J ˆT:ˆPĨˆQö‡P˙‡P˙†P˙†O˙…O˙…N˙…N˙„N˙„M˙ƒM˙ƒM˙‚L˙‚L˙‚K˙K˙Kû€J ¯‚K3‹F ‡QU†Ož…OׅO˙…N˙„N˙„M˙ƒM˙ƒM˙ƒL˙‚L˙‚K˙KķLļ€Kp€J €P‚P3„MS‚NfƒLo‚LlK _IIƒI#ĒU˙ū˙˙˙đ˙˙˙Ā˙˙˙˙ū˙ü˙ø˙đ˙ā˙ĀĀ?€€€€€€ĀĀāāđøøüū˙˙€˙Ā˙ā?˙ø˙ū˙˙˙€˙˙˙ā˙˙˙ū˙astropy-photutils-24be529/docs/_static/photutils_logo_dark_plain_path.svg000066400000000000000000002170551526452531300271740ustar00rootroot00000000000000 Photutils logoimage/svg+xmlPhotutils logoLarry Bradley astropy-photutils-24be529/docs/_static/photutils_logo_light_plain_path.svg000066400000000000000000002170551526452531300273620ustar00rootroot00000000000000 Photutils logoimage/svg+xmlPhotutils logoLarry Bradley astropy-photutils-24be529/docs/_templates/000077500000000000000000000000001526452531300206755ustar00rootroot00000000000000astropy-photutils-24be529/docs/_templates/layout.html000066400000000000000000000012051526452531300230760ustar00rootroot00000000000000{% extends "!layout.html" %} {# Sphinx writes the html_favicon link without a sizes attribute, which makes Chromium prefer the ICO. Both favicon links are written below instead, with the ICO declared as a small fallback so that Chromium prefers the SVG. #} {%- set favicon_url = none %} {%- block linktags %} {{- super() }} {%- endblock %} astropy-photutils-24be529/docs/changelog.rst000066400000000000000000000001131526452531300212140ustar00rootroot00000000000000.. _changelog: ********* Changelog ********* .. include:: ../CHANGES.rst astropy-photutils-24be529/docs/conf.py000066400000000000000000000235061526452531300200450ustar00rootroot00000000000000# 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.v3 import * # noqa: F403 from sphinx_astropy.conf.v3 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), }) # 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 # The heading above the newest "What's New" page in the release notes # index depends on whether this is a development or a release build. whatsnew_heading = 'Development Version' if dev else 'Current Release' rst_epilog += f""" .. |whatsnew_heading| replace:: {whatsnew_heading} """ # -- Options for HTML output -------------------------------------------------- html_theme_options = { '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', }, ], # The theme counts page views only on astropy.org hosts, so the # GoatCounter script would load on every page but never count. 'goatcounter_analytics_url': False, 'pygments_dark_style': 'nord', } html_title = f'{project} {release}' html_show_sourcelink = False html_favicon = os.path.join('_static', 'photutils_logo.ico') html_static_path = ['_static'] templates_path = ['_templates'] 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')] # -- 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+', r'https://ui\.adsabs\.harvard\.edu/.*', # 405 Client Error # Zenodo/doi: 403 Client Error: Forbidden for url r'https://zenodo.org/records/*', r'https://doi.org/*', ] linkcheck_timeout = 180 # -- Hide private base classes from the rendered "Bases:" line ---------------- def _public_ancestors(cls): """ Yield the nearest public ancestors of a private base class. Parameters ---------- cls : type The private base class to resolve. Yields ------ ancestor : type Each nearest public ancestor of ``cls``, in method resolution order. Private ancestors are replaced by their own nearest public ancestors. """ for base in cls.__bases__: if base.__name__.startswith('_'): yield from _public_ancestors(base) else: yield base def _hide_private_bases(app, name, obj, options, bases): # noqa: ARG001 """ Replace private base classes with their nearest public ancestors. A private base class is rendered as unlinked plain text in the "Bases:" line of a class page, because it has no documentation to link to. This handler substitutes the nearest public ancestors instead, so that implementation-detail mixins and common base classes stay out of the public API documentation. Parameters ---------- app : `sphinx.application.Sphinx` The Sphinx application object. name : str The fully qualified name of the class being documented. obj : type The class being documented. options : `sphinx.ext.autodoc.Options` The options given to the class directive. bases : list of type The base classes to render. This list is modified in place. """ public = [] for base in bases: replacements = ([base] if not base.__name__.startswith('_') else _public_ancestors(base)) for cls in replacements: if cls not in public: public.append(cls) # Drop the implicit object base unless it is the only one left if len(public) > 1: public = [cls for cls in public if cls is not object] bases[:] = public def setup(app): """ Connect the photutils-specific Sphinx event handlers. Parameters ---------- app : `sphinx.application.Sphinx` The Sphinx application object. """ app.connect('autodoc-process-bases', _hide_private_bases) astropy-photutils-24be529/docs/development/000077500000000000000000000000001526452531300210625ustar00rootroot00000000000000astropy-photutils-24be529/docs/development/contributing.rst000066400000000000000000000023201526452531300243200ustar00rootroot00000000000000Reporting 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 also be 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 `Astropy Community Code of Conduct `_. This package follows the same workflow and coding guidelines as `Astropy`_. Please read the `Astropy Contributing documentation `_ to get started. astropy-photutils-24be529/docs/development/contributors.rst000066400000000000000000000002651526452531300243540ustar00rootroot00000000000000Contributors ============ For the complete list of contributors please see the `Photutils contributors page on GitHub `_. astropy-photutils-24be529/docs/development/index.rst000066400000000000000000000004671526452531300227320ustar00rootroot00000000000000.. _development: *********** Development *********** For Contributors ---------------- .. toctree:: :maxdepth: 1 contributing.rst contributors.rst For Maintainers --------------- .. toctree:: :maxdepth: 1 releasing.rst License ------- .. toctree:: :maxdepth: 1 license.rst astropy-photutils-24be529/docs/development/license.rst000066400000000000000000000001751526452531300232410ustar00rootroot00000000000000.. _photutils_license: License ======= Photutils is licensed under a 3-clause BSD license: .. include:: ../../LICENSE.rst astropy-photutils-24be529/docs/development/releasing.rst000066400000000000000000000163111526452531300235670ustar00rootroot00000000000000.. 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 git remote for the main repository is named ``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 `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 current "What's New" page (``docs/whats_new/.rst``) to make sure that all the changes are listed. Then commit the changes:: git add docs/whats_new/.rst git commit -m"Finalizing what's new for version " #. 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. Also update the ``version`` and ``date-released`` fields in the ``CITATION.cff`` file. Then commit the changes:: git add CHANGES.rst CITATION.cff 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 `Wheel building 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 newly released version, then move them to the next milestone. After there are no remaining open issues or pull requests for the released version, close its GitHub Milestone. #. Go to `Read the Docs `_ and check that the "stable" docs correspond to the newly released version. Hide any older released versions (i.e., check "Hidden"). #. Add a new "What's New" page (``docs/whats_new/.rst``) for the next release. Update the ``docs/release_notes/index.rst`` file by moving the page of the released version to the top of the "Past Releases" list and adding the new "What's New" page under the ``|whatsnew_heading|`` heading. That heading is set in ``docs/conf.py`` and is rendered as "Development Version" for development builds and as "Current Release" for release builds, so it should not be edited. Then commit the changes:: git add docs/whats_new/.rst docs/release_notes/index.rst git commit -m"Add what's new for version " #. 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 `_. .. _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-24be529/docs/getting_started/000077500000000000000000000000001526452531300217275ustar00rootroot00000000000000astropy-photutils-24be529/docs/getting_started/citation.rst000066400000000000000000000013111526452531300242670ustar00rootroot00000000000000.. _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-24be529/docs/getting_started/importing.rst000066400000000000000000000035141526452531300244740ustar00rootroot00000000000000.. doctest-skip-all .. _importing: ************************ Importing from Photutils ************************ **Photutils** functionality is organized into subpackages that must be imported explicitly. Importing only the top-level ``photutils`` package will not expose its tools. For example, the following code will fail:: >>> import photutils >>> aper = photutils.CircularAperture((10, 20), r=4) AttributeError: module 'photutils' has no attribute 'CircularAperture' Instead, you must import tools from their specific subpackage. For example, to use the aperture photometry tools, you can import a class directly:: >>> from photutils.aperture import CircularAperture >>> aper = CircularAperture((10, 20), r=4) Alternatively, you can import the subpackage itself:: >>> from photutils import aperture >>> aper = aperture.CircularAperture((10, 20), r=4) .. warning:: **Do not import from a subpackage's internal modules.** This is unnecessary and the internal organization of subpackages may change without notice. All public tools are available directly at the **subpackage level**. For example, do **not** import from the internal ``circle`` module within the ``aperture`` subpackage:: >>> # Do not import internal modules >>> 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 or subpackage name 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-24be529/docs/getting_started/index.rst000066400000000000000000000003051526452531300235660ustar00rootroot00000000000000.. _getting_started: *************** Getting Started *************** .. toctree:: :maxdepth: 1 install overview importing pixel_conventions performance_tips citation astropy-photutils-24be529/docs/getting_started/install.rst000066400000000000000000000120111526452531300241220ustar00rootroot00000000000000************ Installation ************ Requirements ============ Photutils has the following strict requirements: * `Python `_ 3.12 or later * `NumPy `_ 2.0 or later * `SciPy `_ 1.15 or later * `Astropy`_ 7.0 or later Photutils also optionally depends on other packages for some features: * `Matplotlib `_ 3.10 or later: Used to power a variety of plotting features (e.g., plotting apertures). * `Regions `_ 0.10 or later: Required to perform aperture photometry using region objects and to convert source segments into region objects. * `GWCS `_ 0.22 or later: Required in `~photutils.datasets.make_gwcs` to create a simple celestial GWCS object. * `asdf `_ 3.3 or later: Required to serialize apertures and PSF models to the `Advanced Scientific Data Format (ASDF) `_ format. * `asdf-astropy `_ 0.10 or later: Required to serialize objects that store a `~astropy.units.Quantity` or a `~astropy.coordinates.SkyCoord` to ASDF, which includes all PSF models and all sky apertures. See :ref:`asdf` for details. * `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.67 or later: Required to display optional progress bars. * `Rasterio `_ 1.4 or later: Required to convert source segments into polygon objects. * `Shapely `_ 2.1 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 precompiled 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 Note that you do not need to install the full `Xcode`_ distribution. 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 test dependencies:: python -m pip install "photutils[test]" 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-24be529/docs/getting_started/overview.rst000066400000000000000000000023231526452531300243270ustar00rootroot00000000000000******** Overview ******** 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 where you think API stability is important, please let us know as part of the development process. astropy-photutils-24be529/docs/getting_started/performance_tips.rst000066400000000000000000000063711526452531300260300ustar00rootroot00000000000000.. _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. If Bottleneck is installed, Photutils will automatically leverage it for these operations to improve performance, particularly for workflows involving NaN values. This acceleration is currently limited to input arrays with ``float64`` data types and :ref:`native byte order `. .. 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. Bottleneck acceleration is used internally by the following Photutils subpackages: * `~photutils.background`: background and background RMS estimation (e.g., `~photutils.background.MedianBackground`) * `~photutils.detection`: source detection peak finding * `~photutils.profiles`: profile normalization * `~photutils.psf`: ePSF building (e.g., `~photutils.psf.EPSFBuilder`) * `~photutils.segmentation`: detection threshold estimation (e.g., `~photutils.segmentation.detect_threshold`) 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 = data.view(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. .. _multithreading-performance: Multithreading ============== Several classes and functions accept an ``n_threads`` keyword to perform their calculations using multiple threads: * `~photutils.aperture.AperturePhotometry` and `~photutils.aperture.ApertureStats` * `~photutils.background.Background2D` and `~photutils.background.LocalBackground` * `~photutils.segmentation.SourceCatalog`, `~photutils.segmentation.SourceFinder`, and :func:`~photutils.segmentation.deblend_sources` * `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` The default is ``n_threads=1``. The compiled code used by the aperture, background, and segmentation tools releases the Python global interpreter lock (GIL), so their threads run in parallel on any Python build. The PSF photometry threads run in parallel only on a free-threaded Python build. astropy-photutils-24be529/docs/getting_started/pixel_conventions.rst000066400000000000000000000027361526452531300262370ustar00rootroot00000000000000**************************** Pixel Coordinate Conventions **************************** Photutils uses 0-based pixel coordinates, consistent with standard Python and NumPy indexing. In this convention, integer pixel coordinates represent the *centers* of the pixels. For example, the center of the first pixel is at coordinate ``0``, meaning that pixel spans the range from ``-0.5`` to ``0.5``. For a two-dimensional image, the pixel center at ``(x, y) = (0, 0)`` corresponds to the bottom-left array element. Consequently, this first pixel spans from ``-0.5`` to ``0.5`` in both the ``x`` and ``y`` directions. Because image data are standard NumPy arrays, they are accessed as ``image[yi, xi]``, where ``yi`` is the row index (the first, or slow, array axis) and ``xi`` is the column index (the second, or fast, array axis). It is important to note that this array indexing order (``[y, x]``) is the reverse of the spatial coordinate order (``(x, y)``). This 0-based convention differs from the `FITS WCS`_ standard, which uses 1-based pixel coordinates. In the FITS convention, the center of the bottom-left pixel is ``(x, y) = (1, 1)``. Software such as `ds9`_, `SourceExtractor`_, and IRAF follows the FITS convention. Therefore, to match coordinates from these tools with Photutils, you must subtract 1 from their ``x`` and ``y`` coordinates. .. _FITS WCS: https://fits.gsfc.nasa.gov/fits_wcs.html .. _ds9: http://ds9.si.edu/ .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ astropy-photutils-24be529/docs/index.rst000066400000000000000000000046021526452531300204030ustar00rootroot00000000000000 :tocdepth: 3 .. |br| raw:: html
.. image:: _static/photutils_logo_light_plain_path.svg :class: only-light index-logo :width: 55% :align: center :alt: Photutils logo .. image:: _static/photutils_logo_dark_plain_path.svg :class: only-dark index-logo :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:: Getting Started :link: getting_started/index :link-type: doc :text-align: center New to Photutils? Check out the getting started guides. They contain an overview of Photutils and an introduction to its main concepts. .. grid-item-card:: User Guide :link: user_guide/index :link-type: doc :text-align: center The user guide provides in-depth information on the key concepts of Photutils with useful background information and explanation. .. grid-item-card:: API Reference Guide :link: reference/index :link-type: doc :text-align: center 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. astropy-photutils-24be529/docs/make.bat000066400000000000000000000014401526452531300201440ustar00rootroot00000000000000@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-24be529/docs/reference/000077500000000000000000000000001526452531300204765ustar00rootroot00000000000000astropy-photutils-24be529/docs/reference/aperture_api.rst000066400000000000000000000004251526452531300237110ustar00rootroot00000000000000 =============================================== Aperture Photometry (:mod:`photutils.aperture`) =============================================== .. automodapi:: photutils.aperture :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/aperture` astropy-photutils-24be529/docs/reference/background_api.rst000066400000000000000000000004071526452531300242010ustar00rootroot00000000000000 ========================================= Backgrounds (:mod:`photutils.background`) ========================================= .. automodapi:: photutils.background :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/background` astropy-photutils-24be529/docs/reference/centroids_api.rst000066400000000000000000000003741526452531300240570ustar00rootroot00000000000000 ====================================== Centroids (:mod:`photutils.centroids`) ====================================== .. automodapi:: photutils.centroids :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/centroids` astropy-photutils-24be529/docs/reference/datasets_api.rst000066400000000000000000000004411526452531300236700ustar00rootroot00000000000000 =================================================== Datasets and Simulation (:mod:`photutils.datasets`) =================================================== .. automodapi:: photutils.datasets :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/datasets` astropy-photutils-24be529/docs/reference/detection_api.rst000066400000000000000000000004621526452531300240410ustar00rootroot00000000000000 ======================================================== Point-like Source Detection (:mod:`photutils.detection`) ======================================================== .. automodapi:: photutils.detection :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/detection` astropy-photutils-24be529/docs/reference/geometry_api.rst000066400000000000000000000004221526452531300237120ustar00rootroot00000000000000 ============================================== Geometry Functions (:mod:`photutils.geometry`) ============================================== .. automodapi:: photutils.geometry :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/geometry` astropy-photutils-24be529/docs/reference/index.rst000066400000000000000000000023431526452531300223410ustar00rootroot00000000000000.. _api_reference: ******************* API Reference Guide ******************* 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-24be529/docs/reference/isophote_api.rst000066400000000000000000000004601526452531300237130ustar00rootroot00000000000000 ======================================================== Elliptical Isophote Analysis (:mod:`photutils.isophote`) ======================================================== .. automodapi:: photutils.isophote :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/isophote` astropy-photutils-24be529/docs/reference/morphology_api.rst000066400000000000000000000004561526452531300242650ustar00rootroot00000000000000 ====================================================== Morphological Properties (:mod:`photutils.morphology`) ====================================================== .. automodapi:: photutils.morphology :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/morphology` astropy-photutils-24be529/docs/reference/profiles_api.rst000066400000000000000000000004421526452531300237040ustar00rootroot00000000000000 ==================================== 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-24be529/docs/reference/psf_api.rst000066400000000000000000000003551526452531300226540ustar00rootroot00000000000000 ===================================== PSF Photometry (:mod:`photutils.psf`) ===================================== .. automodapi:: photutils.psf :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/psf` astropy-photutils-24be529/docs/reference/psf_matching_api.rst000066400000000000000000000004241526452531300245230ustar00rootroot00000000000000 ============================================ PSF Matching (:mod:`photutils.psf_matching`) ============================================ .. automodapi:: photutils.psf_matching :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/psf_matching` astropy-photutils-24be529/docs/reference/segmentation_api.rst000066400000000000000000000004461526452531300245620ustar00rootroot00000000000000 ================================================== Image Segmentation (:mod:`photutils.segmentation`) ================================================== .. automodapi:: photutils.segmentation :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/segmentation` astropy-photutils-24be529/docs/reference/utils_api.rst000066400000000000000000000004001526452531300232130ustar00rootroot00000000000000 ========================================== Utility Functions (:mod:`photutils.utils`) ========================================== .. automodapi:: photutils.utils :no-heading: :inherited-members: User Guide ^^^^^^^^^^ :doc:`../user_guide/utils` astropy-photutils-24be529/docs/release_notes/000077500000000000000000000000001526452531300213705ustar00rootroot00000000000000astropy-photutils-24be529/docs/release_notes/index.rst000066400000000000000000000024731526452531300232370ustar00rootroot00000000000000************* Release Notes ************* Changelog ========= The changelog lists every change in each release, including bug-fix releases. Entries are grouped into new features, bug fixes, and API changes, and then by subpackage. .. toctree:: :maxdepth: 1 Changelog <../changelog> What's New ========== Each page below describes the most significant changes in a feature release, often with examples. See the changelog for the complete list of changes. |whatsnew_heading| ------------------ .. toctree:: :maxdepth: 1 ../whats_new/3.1.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/3.0.rst ../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-24be529/docs/user_guide/000077500000000000000000000000001526452531300206735ustar00rootroot00000000000000astropy-photutils-24be529/docs/user_guide/aperture.rst000066400000000000000000001604161526452531300232640ustar00rootroot00000000000000.. _photutils-aperture: Aperture Photometry (`photutils.aperture`) ========================================== Introduction ------------ The :class:`~photutils.aperture.AperturePhotometry` class and the :class:`~photutils.aperture.ApertureStats` class are the primary tools for performing aperture photometry. They work in conjunction with the aperture classes. The :class:`~photutils.aperture.AperturePhotometry` class sums the (weighted) data values within one or more apertures and reports the aperture fluxes, uncertainties, unmasked overlap areas, and bitwise quality flags. The :class:`~photutils.aperture.ApertureStats` class computes per-source statistics (e.g., mean, median, standard deviation, sigma-clipped values, and morphological properties) for the pixels within an aperture. The legacy :func:`~photutils.aperture.aperture_photometry` function performs basic aperture photometry, returning only the aperture sums and (optionally) their uncertainties. It is retained for backwards compatibility, but new features are added only to the :class:`~photutils.aperture.AperturePhotometry` and :class:`~photutils.aperture.ApertureStats` classes. .. tip:: Which tool should I use? * Use :class:`~photutils.aperture.AperturePhotometry` for aperture fluxes (sums), errors, areas, and quality flags. The calculated values can be accessed as class attributes or output as an Astropy `~astropy.table.QTable`. For aperture photometry, it is faster than :class:`~photutils.aperture.ApertureStats`. * Use :class:`~photutils.aperture.ApertureStats` for per-source statistics such as the median, standard deviation, sigma-clipped values, and morphological properties. * Use the legacy :func:`~photutils.aperture.aperture_photometry` function only if you need its original output columns for backwards compatibility. .. _photutils-apertures: Apertures --------- Photutils provides several apertures defined in pixel or sky coordinates. .. list-table:: :header-rows: 1 * - Pixel coordinates - Sky coordinates * - `~photutils.aperture.CircularAperture` - `~photutils.aperture.SkyCircularAperture` * - `~photutils.aperture.CircularAnnulus` - `~photutils.aperture.SkyCircularAnnulus` * - `~photutils.aperture.EllipticalAperture` - `~photutils.aperture.SkyEllipticalAperture` * - `~photutils.aperture.EllipticalAnnulus` - `~photutils.aperture.SkyEllipticalAnnulus` * - `~photutils.aperture.RectangularAperture` - `~photutils.aperture.SkyRectangularAperture` * - `~photutils.aperture.RectangularAnnulus` - `~photutils.aperture.SkyRectangularAnnulus` * - `~photutils.aperture.PolygonAperture` - `~photutils.aperture.SkyPolygonAperture` The :class:`~photutils.aperture.AperturePhotometry` class and the :class:`~photutils.aperture.ApertureStats` class both accept :class:`~photutils.aperture.Aperture` objects. When using sky-based apertures, a WCS transformation must also be provided. They also accept supported `regions.Region` objects, i.e., region classes that correspond to the aperture classes listed above. The :class:`~photutils.aperture.AperturePhotometry` class additionally accepts a list of :class:`~photutils.aperture.Aperture` and/or `regions.Region` objects, provided they all have identical positions. The :func:`~photutils.aperture.region_to_aperture` convenience function can be used to convert a `regions.Region` object to the corresponding :class:`~photutils.aperture.Aperture` object. .. _creating-aperture-objects: Creating Aperture Objects ------------------------- The first step in performing aperture photometry is to create an aperture object. An aperture is defined by one or more positions and the parameters that specify its size and, where applicable, its orientation (e.g., for elliptical apertures). The following example creates a circular aperture in pixel coordinates using the :class:`~photutils.aperture.CircularAperture` class:: >>> from photutils.aperture import CircularAperture >>> positions = [(71.4, 23.9), (42.1, 41.7), (54.6, 68.2)] >>> aperture = CircularAperture(positions, r=3.1) The positions can be specified as a single ``(x, y)`` tuple, a list of ``(x, y)`` tuples, or an ``N×2`` array, where ``N`` is the number of positions. In this example, three circular apertures with a radius of 3.1 pixels are created at ``(x, y)`` pixel coordinates ``(71.4, 23.9)``, ``(42.1, 41.7)``, and ``(54.6, 68.2)``. Creating an aperture in sky coordinates is similar. First, define the sky coordinates using the :class:`~astropy.coordinates.SkyCoord` class, then create the aperture using the :class:`~photutils.aperture.SkyCircularAperture` class:: >>> from astropy import units as u >>> from astropy.coordinates import SkyCoord >>> from photutils.aperture import SkyCircularAperture >>> positions = SkyCoord([101.20, 101.21], [45.14, 45.12], unit='deg', ... frame='icrs') >>> aperture = SkyCircularAperture(positions, r=4.0 * u.arcsec) .. note:: Sky apertures are not defined entirely in sky coordinates. The aperture center is specified by sky coordinates, while the aperture size is specified in angular units, and the orientation (``theta``, where applicable) is defined in the coordinate frame of the aperture positions. When converting to pixel coordinates, the aperture size and orientation are transformed using the local pixel scale and rotation at the aperture center. If the celestial frame of the WCS differs from the frame of the aperture positions (e.g., ICRS positions with a Galactic WCS), the orientation angle is also converted to the equivalent angle in the WCS frame, so the aperture keeps the same orientation on the sky. Projection distortions are not taken into account. Consequently, sky apertures are **not** apertures defined on the celestial sphere. Instead, they represent aperture shapes on an image that are anchored to a sky position. This design preserves the aperture shape when converting between sky and pixel coordinates, which would not generally be possible if the aperture were defined as a true region on the celestial sphere. Converting Between Pixel and Sky Apertures ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The pixel apertures can be converted to sky apertures, and vice versa, given a WCS object. To convert a pixel aperture to a sky aperture, use the :meth:`~photutils.aperture.PixelAperture.to_sky` method. The following example shows how to convert a :class:`~photutils.aperture.CircularAperture` to a :class:`~photutils.aperture.SkyCircularAperture` using a sample :class:`~astropy.wcs.WCS` object:: >>> from photutils.datasets import make_wcs >>> wcs = make_wcs((100, 100)) >>> aperture = CircularAperture((12.3, 18.7), r=4.0) >>> sky_aperture = aperture.to_sky(wcs) >>> sky_aperture , r=0.39999999972155825 arcsec)> To convert a sky aperture to a pixel aperture, use the :meth:`~photutils.aperture.SkyAperture.to_pixel` method:: >>> position = SkyCoord(197.89228076, -1.36685926, unit='deg', ... frame='icrs') >>> aperture = SkyCircularAperture(position, r=0.4 * u.arcsec) >>> pix_aperture = aperture.to_pixel(wcs) >>> pix_aperture By default, a circular aperture converts to a circle whose radius is scaled by the mean pixel scale. A circle on the sky maps to an ellipse in pixel coordinates (and vice versa) whenever the pixels are not square or the WCS is sheared. Pass ``as_ellipse=True`` to the ``to_pixel`` or ``to_sky`` method of a circular aperture or annulus to return that ellipse instead. For example, with pixels that are 0.1 arcsec wide and 0.2 arcsec tall, a 0.4 arcsec sky circle becomes a pixel ellipse with semiaxes of 4 and 2 pixels:: >>> from astropy.wcs import WCS >>> wcs2 = WCS(naxis=2) >>> wcs2.wcs.ctype = ['RA---TAN', 'DEC--TAN'] >>> wcs2.wcs.crval = [197.89228076, -1.36685926] >>> wcs2.wcs.crpix = [50.0, 50.0] >>> wcs2.wcs.cdelt = [-0.1 / 3600, 0.2 / 3600] >>> ellipse = aperture.to_pixel(wcs2, as_ellipse=True) >>> print(ellipse.a, ellipse.b) 3.999999998537498 2.0000000002783653 .. note:: Each aperture has a single fixed shape that is applied identically at every position (e.g., a circular pixel aperture has the same radius at all positions). However, a WCS may have a spatially varying pixel scale, rotation, or distortion, so there is generally no single transformation that preserves the aperture shape at every position simultaneously. To ensure a deterministic conversion, the local WCS properties (e.g., pixel scale and rotation angle) are computed at a single reference position. By convention, this is the **first** aperture position. This behavior applies to all aperture classes, including :class:`~photutils.aperture.PolygonAperture` and :class:`~photutils.aperture.SkyPolygonAperture`, whose vertex offsets are transformed using the WCS evaluated at the first position. Consequently, when converting apertures with multiple positions using a WCS with significant spatial variations, the converted aperture shapes may become increasingly inaccurate for positions farther from the first position. Polygon Apertures ^^^^^^^^^^^^^^^^^ In addition to the built-in circular, elliptical, and rectangular shapes, arbitrary polygon apertures are supported via the `~photutils.aperture.PolygonAperture` and `~photutils.aperture.SkyPolygonAperture` classes. Like the other aperture types, a polygon aperture has a single fixed shape (defined by its ``vertex_offsets``) that can be applied at one or more positions:: >>> import numpy as np >>> from photutils.aperture import PolygonAperture >>> positions = [(71.4, 23.9), (42.1, 41.7), (54.6, 68.2)] >>> offsets = [(-3.0, -2.0), (3.0, -2.0), (3.0, 2.0), (-3.0, 2.0)] >>> aperture = PolygonAperture(positions, offsets) The polygon must be simple (non-self-intersecting) with at least three vertices, but it does not need to be convex. Nonconvex shapes such as L-shapes or stars are fully supported. The vertices may be input in either clockwise or counterclockwise order. They are normalized to counterclockwise order internally. A single polygon can also be constructed directly from its absolute vertex coordinates using the :meth:`~photutils.aperture.PolygonAperture.from_vertices` class method, which sets the aperture position to the polygon centroid:: >>> vertices = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0)] >>> aperture = PolygonAperture.from_vertices(vertices) Regular polygons (equal-length sides and equal interior angles) can be created with the :meth:`~photutils.aperture.PolygonAperture.from_regular_polygon` class method by specifying a center, the number of vertices, the outer radius (circumradius), and an optional rotation angle:: >>> hexagon = PolygonAperture.from_regular_polygon((31.4, 32.9), ... n_vertices=6, ... radius=5.0) For regular polygons, the ``outer_radius``, ``inner_radius``, ``side_length``, ``interior_angle``, ``exterior_angle``, and ``theta`` properties expose the geometric parameters of the shape. Converting to Polygon Apertures ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The built-in :class:`~photutils.aperture.CircularAperture`, :class:`~photutils.aperture.EllipticalAperture`, :class:`~photutils.aperture.RectangularAperture`, :class:`~photutils.aperture.SkyCircularAperture`, :class:`~photutils.aperture.SkyEllipticalAperture`, and :class:`~photutils.aperture.SkyRectangularAperture` classes provide a ``to_polygon`` method that returns a :class:`~photutils.aperture.PolygonAperture` or :class:`~photutils.aperture.SkyPolygonAperture`. For circular and elliptical apertures, the returned polygon approximates the aperture boundary. For rectangular apertures, it is exactly equivalent. Performing Aperture Photometry ------------------------------ After the aperture object is created, we can then perform the photometry using the :class:`~photutils.aperture.AperturePhotometry` class. We start by defining the aperture (at three positions) as described above:: >>> from photutils.aperture import CircularAperture >>> positions = [(71.4, 23.9), (42.1, 41.7), (54.6, 68.2)] >>> aperture = CircularAperture(positions, r=3.1) We then create an :class:`~photutils.aperture.AperturePhotometry` instance with the data and the apertures. Note that :class:`~photutils.aperture.AperturePhotometry` 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 AperturePhotometry >>> data = np.ones((100, 100)) >>> phot = AperturePhotometry(data, aperture) The aperture fluxes, uncertainties, unmasked overlap areas, and quality flags are available as lazily computed attributes (see :attr:`~photutils.aperture.AperturePhotometry.flux`, :attr:`~photutils.aperture.AperturePhotometry.flux_err`, :attr:`~photutils.aperture.AperturePhotometry.area`, and :attr:`~photutils.aperture.AperturePhotometry.flags`):: >>> print(phot.flux) [30.1907054 30.1907054 30.1907054] The results can also be output as an Astropy `~astropy.table.QTable` using the :meth:`~photutils.aperture.AperturePhotometry.to_table` method:: >>> phot_table = phot.to_table() >>> phot_table['flux'].info.format = '%.8g' # for consistent table output >>> phot_table['area'].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux flux_err area flags pix2 --- -------- -------- --------- -------- --------- ----- 1 71.4 23.9 30.190705 nan 30.190705 0 2 42.1 41.7 30.190705 nan 30.190705 0 3 54.6 68.2 30.190705 nan 30.190705 0 The default table has seven columns, named ``'id'``, ``'x_center'``, ``'y_center'``, ``'flux'``, ``'flux_err'``, ``'area'``, and ``'flags'``. Because no ``error`` was input, the ``'flux_err'`` column is filled with NaN values (see :ref:`error_estimation`). The ``'area'`` column gives the total unmasked overlap area of the aperture with the data (in ``pix**2``). It is equivalent to the aperture :meth:`~photutils.aperture.PixelAperture.area_overlap` method computed with the same inputs. The ``'flags'`` column contains bitwise quality flags for each aperture (see :ref:`aperture_flags`). A value of 0 means that no issues were detected. Since all the data values are 1.0, the aperture sums are equal to the area of a circle with a radius of 3.1:: >>> print(np.pi * 3.1 ** 2) 30.190705401 .. _aperture-mask-methods: Aperture and Pixel Overlap -------------------------- The overlap of the aperture with the data pixels can be handled using one of three methods: * ``'exact'`` (default): Calculates the exact geometric intersection of the aperture with each pixel. This is the most precise and preferred method. * ``'center'``: A pixel is assigned a weight of 1 if its center falls within the aperture, and 0 otherwise. * ``'subpixel'``: Pixels are divided into a subgrid of :math:`N \times N` subpixels (where :math:`N` is set by the ``subpixels`` keyword). The pixel weight is the fraction of subpixel centers that fall within the aperture. .. note:: For the ``'center'`` and ``'subpixel'`` methods, a pixel (or subpixel) center is considered "inside" the aperture only if it lies *strictly* inside the aperture boundary. A center that lands exactly on the aperture boundary is treated as outside and assigned a weight of 0. This is particularly relevant for the ``'center'`` method when using apertures aligned perfectly with the pixel grid. For the ``'subpixel'`` method it is less relevant, since subpixel centers seldom land exactly on the boundary. This example uses the ``'subpixel'`` method where pixels are resampled by a factor of 5 (``subpixels=5``) in each dimension:: >>> phot = AperturePhotometry(data, aperture, method='subpixel', ... subpixels=5) >>> phot_table = phot.to_table() >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux flux_err area flags pix2 --- -------- -------- ----- -------- ----- ----- 1 71.4 23.9 30.2 nan 30.2 0 2 42.1 41.7 29.6 nan 29.6 0 3 54.6 68.2 29.96 nan 29.96 0 Note that the ``'subpixel'`` sums differ from the exact value of 30.190705 (see above). They also differ slightly from one another, even though every aperture has the same radius. This occurs because subpixel sampling is a discrete approximation: the number of subpixel centers that fall within an aperture depends on the aperture center's position relative to the pixel grid. For the ``'subpixel'`` method, the default value is ``subpixels=5``, meaning that each pixel is divided into a 5 × 5 grid of smaller subpixels (25 subpixels total). This is the sampling method and subsampling factor used by `SourceExtractor `_. Increasing ``subpixels`` improves the accuracy of the approximation, but also increases the computation time. .. _error_estimation: Aperture Photometry Error Estimation ------------------------------------ If the ``error`` keyword is provided to :class:`~photutils.aperture.AperturePhotometry`, the ``'flux_err'`` column contains the propagated uncertainty of ``'flux'``. If ``error`` is not provided, the ``'flux_err'`` column is filled with NaN values. For example, suppose the uncertainty of each pixel value has already been calculated and stored in the array ``error``:: >>> positions = [(71.4, 23.9), (42.1, 41.7), (54.6, 68.2)] >>> aperture = CircularAperture(positions, r=3.0) >>> data = np.ones((100, 100)) >>> error = 0.1 * data >>> phot = AperturePhotometry(data, aperture, error=error) >>> phot_table = phot.to_table() >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux flux_err area flags pix2 --- -------- -------- --------- ---------- --------- ----- 1 71.4 23.9 28.274334 0.50201964 28.274334 0 2 42.1 41.7 28.274334 0.5002027 28.274334 0 3 54.6 68.2 28.274334 0.50259927 28.274334 0 The flux is the sum of the pixel values weighted by their aperture overlap fractions, :math:`F = \sum_{i \in A} w_i f_i`, so the ``'flux_err'`` values are calculated by standard error propagation as: .. math:: \Delta F = \sqrt{\sum_{i \in A} w_i^2 \sigma_{\mathrm{tot}, i}^2} where :math:`A` denotes the unmasked pixels within the aperture, :math:`w_i` is the aperture overlap fraction of pixel :math:`i` (1 for pixels entirely within the aperture), and :math:`\sigma_{\mathrm{tot}, i}` is the corresponding value from the input ``error`` array. Because the variance of each boundary pixel is weighted by its squared overlap fraction, the flux error depends on the subpixel placement of the aperture (note the slightly different ``'flux_err'`` values in the example above). In the example above, the ``error`` keyword is assumed to specify the *total* uncertainty. That is, it either includes the Poisson noise from individual sources or the source Poisson noise is negligible. In many cases, however, the available uncertainty image represents only the background noise. Such a background-only error array does not include the additional Poisson noise from bright sources. To include source Poisson noise, the :func:`~photutils.utils.calc_total_error` function can be used to calculate the total error. For example, suppose we have a background-only error image named ``bkg_error``. If the data are in units of electrons/s, we use the exposure time as the effective gain:: >>> from photutils.utils import calc_total_error >>> bkg_error = 0.1 * data # background-only error >>> effective_gain = 500 # seconds >>> error = calc_total_error(data, bkg_error, effective_gain) >>> phot = AperturePhotometry(data, aperture, error=error) >>> phot_table = phot.to_table() >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux flux_err area flags pix2 --- -------- -------- --------- ---------- --------- ----- 1 71.4 23.9 28.274334 0.54993497 28.274334 0 2 42.1 41.7 28.274334 0.5479446 28.274334 0 3 54.6 68.2 28.274334 0.55056991 28.274334 0 Note that the ``'flux_err'`` values are now larger than in the previous example because they include the additional Poisson noise from the source. Refer to the :func:`~photutils.utils.calc_total_error` documentation for more details on the calculation of the total error. Aperture Photometry with Multiple Apertures at Each Position ------------------------------------------------------------ While :class:`~photutils.aperture.Aperture` objects support multiple positions, a single aperture object must have the same size and shape at every position (e.g., the same radius and orientation). To perform photometry using multiple aperture sizes or shapes at each position, pass a list of aperture objects to the :class:`~photutils.aperture.AperturePhotometry` class. In this case, all aperture objects in the list must have identical position(s). For example, suppose we want to measure three circular apertures with radii of 3, 4, and 5 pixels at each source position. We therefore create a list of aperture objects, where each object has the same positions but a different radius. We also pass the ``error`` array defined above so that the ``'flux_err'`` columns are populated:: >>> positions = [(71.4, 23.9), (42.1, 41.7), (54.6, 68.2)] >>> radii = [3.0, 4.0, 5.0] >>> apertures = [CircularAperture(positions, r=r) for r in radii] >>> phot = AperturePhotometry(data, apertures, error=error) >>> phot_table = phot.to_table() >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux_0 flux_1 flux_2 flux_err_0 flux_err_1 flux_err_2 area_0 area_1 area_2 flags_0 flags_1 flags_2 pix2 pix2 pix2 --- -------- -------- --------- --------- --------- ---------- ---------- ---------- --------- --------- --------- ------- ------- ------- 1 71.4 23.9 28.274334 50.265482 78.539816 0.54993497 0.74349857 0.93847179 28.274334 50.265482 78.539816 0 0 0 2 42.1 41.7 28.274334 50.265482 78.539816 0.5479446 0.74220043 0.93687955 28.274334 50.265482 78.539816 0 0 0 3 54.6 68.2 28.274334 50.265482 78.539816 0.55056991 0.74457671 0.9390589 28.274334 50.265482 78.539816 0 0 0 When a list of aperture objects is provided, the output table column names are appended with the index of the aperture within the input list (e.g., ``flux_0`` for the first aperture, ``flux_1`` for the second, and so on). Other aperture classes have additional parameters that define their size and orientation. For example, an elliptical aperture requires ``a``, ``b``, and ``theta`` parameters:: >>> 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 = AperturePhotometry(data, apertures, error=error) >>> phot_table = phot.to_table() >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux flux_err area flags pix2 --- -------- -------- -------- ---------- -------- ----- 1 71.4 23.9 47.12389 0.71829412 47.12389 0 2 42.1 41.7 47.12389 0.71591942 47.12389 0 3 54.6 68.2 47.12389 0.71923666 47.12389 0 To measure photometry using multiple elliptical apertures, provide a list of aperture objects with identical positions, but with different ``a``, ``b``, and/or ``theta`` parameters:: >>> 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, strict=True)] >>> phot = AperturePhotometry(data, apertures, error=error) >>> phot_table = phot.to_table() >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux_0 flux_1 flux_2 flux_err_0 flux_err_1 flux_err_2 area_0 area_1 area_2 flags_0 flags_1 flags_2 pix2 pix2 pix2 --- -------- -------- -------- --------- --------- ---------- ---------- ---------- -------- --------- --------- ------- ------- ------- 1 71.4 23.9 47.12389 75.398224 109.95574 0.71829412 0.917401 1.1158155 47.12389 75.398224 109.95574 0 0 0 2 42.1 41.7 47.12389 75.398224 109.95574 0.71591942 0.91551562 1.1140582 47.12389 75.398224 109.95574 0 0 0 3 54.6 68.2 47.12389 75.398224 109.95574 0.71923666 0.91843047 1.1165584 47.12389 75.398224 109.95574 0 0 0 Aperture Photometry on a 3D Data Cube ------------------------------------- :class:`~photutils.aperture.AperturePhotometry` operates on a single 2D image. To measure a source in a 3D data cube (e.g., a time series of images of the same field or a spectroscopic integral field unit (IFU) data cube), apply the same aperture to each 2D image in the stack and collect the measurements into an output array or table. For a time series of images, these measurements form a light curve. Here we create a small stack of five images in which a single source varies in brightness from frame to frame, and measure its flux and uncertainty in each frame using the same circular aperture. Build the list of results objects first (a single photometry pass per frame), then read as many attributes as needed:: >>> import numpy as np >>> from photutils.aperture import AperturePhotometry, CircularAperture >>> scales = np.array([1.0, 1.5, 2.0, 1.8, 1.2]) >>> cube = scales[:, None, None] * np.ones((5, 50, 50)) >>> error = 0.1 * np.ones((50, 50)) >>> aperture = CircularAperture((25, 25), r=5) >>> results = [AperturePhotometry(image, aperture, error=error) ... for image in cube] >>> flux = np.array([phot.flux for phot in results]) >>> flux_err = np.array([phot.flux_err for phot in results]) >>> print(flux) [ 78.53981634 117.80972451 157.07963268 141.37166941 94.24777961] >>> print(flux_err) [0.85273022 0.85273022 0.85273022 0.85273022 0.85273022] Each element of ``flux`` and ``flux_err`` corresponds to one image in the cube, together giving the source's light curve and its uncertainty. The same pattern extends to multiple sources and to other per-frame quantities: read the desired attribute (e.g., :attr:`~photutils.aperture.AperturePhotometry.flags`) from each results object, or build a table per frame with :meth:`~photutils.aperture.AperturePhotometry.to_table`. Masking Pixels in Aperture Photometry ------------------------------------- Masking Bad Pixels ^^^^^^^^^^^^^^^^^^ Pixels can be excluded (e.g., bad pixels) from the aperture photometry by providing a boolean image mask via the ``mask`` keyword:: >>> data = np.ones((5, 5)) >>> aperture = CircularAperture((2, 2), r=2.0) >>> mask = np.zeros(data.shape, dtype=bool) >>> data[2, 2] = 100.0 # bad pixel >>> mask[2, 2] = True # mask the bad pixel >>> phot1 = AperturePhotometry(data, aperture, mask=mask) >>> print(phot1.flux) 11.566370614359172 The result is very different if a ``mask`` image is not provided:: >>> phot2 = AperturePhotometry(data, aperture) >>> print(phot2.flux) 111.56637061435919 For :class:`~photutils.aperture.AperturePhotometry` and :class:`~photutils.aperture.ApertureStats`, nonfinite values (NaN or Inf) in the input ``data`` are automatically masked, so they are automatically excluded from calculations. For example:: >>> data = np.ones((5, 5)) >>> data[2, 2] = np.nan >>> aperture = CircularAperture((2, 2), r=2.0) >>> phot = AperturePhotometry(data, aperture) >>> print(phot.flux) 11.566370614359172 Masking Neighboring Sources with a Segmentation Image ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :class:`~photutils.aperture.AperturePhotometry` and :class:`~photutils.aperture.ApertureStats` support masking or correcting flux contamination from neighboring sources using a segmentation image (see :ref:`Image Segmentation `). A segmentation image is an integer image with the same shape as the data, where background pixels have a value of 0 and sources are labeled with positive integers. The behavior is controlled by the ``mask_method`` keyword, which accepts one of five values: * ``'none'`` (default): The segmentation image is ignored and all pixels within the aperture are included. * ``'mask'``: Pixels belonging to neighboring sources (labeled, but not the target source) are excluded. * ``'source_only'``: Only pixels belonging to the target source are included. Both neighboring sources and background pixels are excluded. * ``'background_only'``: Only background pixels are included. Pixels belonging to any source are excluded, including the source at the aperture position (if any). This method is intended for estimating the local background, e.g., within an annulus aperture. * ``'correct'``: Pixels belonging to neighboring sources are replaced by the values of the pixels mirrored across the aperture center. If a mirror pixel is unavailable, the pixel is excluded. The ``labels`` keyword is required whenever ``segmentation_image`` is provided and ``mask_method`` is ``'mask'``, ``'source_only'``, or ``'correct'``. It specifies the target source label associated with each aperture position, and it must have the same length as the number of aperture positions. Each nonzero label must be present in the segmentation image. A label of 0 disables the segmentation masking for that aperture. The ``'background_only'`` method has no target source, so it does not use the ``labels`` keyword. In this example, a circular aperture centered on the target source (label 1) also overlaps a bright neighboring source (label 2):: >>> import numpy as np >>> from photutils.aperture import AperturePhotometry, CircularAperture >>> from photutils.segmentation import SegmentationImage >>> data = np.ones((11, 11)) >>> data[4:7, 4:7] = 10.0 # target source >>> data[4:7, 7:10] = 50.0 # bright neighbor >>> segm = np.zeros((11, 11), dtype=int) >>> segm[4:7, 4:7] = 1 >>> segm[4:7, 7:10] = 2 >>> segm = SegmentationImage(segm) >>> aperture = CircularAperture((5, 5), r=4) Without masking, the aperture sum includes the neighbor's flux:: >>> phot1 = AperturePhotometry(data, aperture) >>> print(phot1.flux) 484.67784806278354 Masking the neighboring source excludes its flux:: >>> phot2 = AperturePhotometry(data, aperture, segmentation_image=segm, ... labels=1, mask_method='mask') >>> print(phot2.flux) 124.05298520018465 The ``'correct'`` method instead replaces the neighbor's pixels with values mirrored across the aperture center, recovering an estimate of the obscured flux:: >>> phot3 = AperturePhotometry(data, aperture, segmentation_image=segm, ... labels=1, mask_method='correct') >>> print(phot3.flux) 131.26548245743663 These keywords are also available in :class:`~photutils.aperture.ApertureStats`. For example, the ``'background_only'`` method excludes the pixels of every source from a local background estimate in an annulus around the target source:: >>> from photutils.aperture import ApertureStats, CircularAnnulus >>> annulus = CircularAnnulus((5, 5), r_in=2, r_out=5) >>> stats1 = ApertureStats(data, annulus) >>> print(stats1.mean) 8.35 >>> stats2 = ApertureStats(data, annulus, segmentation_image=segm, ... mask_method='background_only') >>> print(stats2.mean) 1.0 .. _aperture_flags: Aperture Quality Flags ---------------------- The :class:`~photutils.aperture.AperturePhotometry` and :class:`~photutils.aperture.ApertureStats` classes include bitwise quality flags for each source in a ``'flags'`` column or attribute. See the :func:`~photutils.aperture.decode_aperture_flags` function for a definition of the flags. This function can be used to decode the flags into human-readable names. Multiple conditions combine bitwise. For example, an aperture that extends beyond the data edge and also contains a masked pixel has ``flags = 10`` (2 + 8). Note that ``'no_overlap'`` always implies ``'no_pixels'`` (bits 1 + 4 = 5). For example:: >>> import numpy as np >>> from photutils.aperture import (AperturePhotometry, CircularAperture, ... decode_aperture_flags) >>> data = np.ones((25, 25)) >>> mask = np.zeros(data.shape, dtype=bool) >>> mask[12, 12] = True # bad pixel inside the first aperture >>> positions = [(12.0, 12.0), (0.0, 12.0), (-50.0, 12.0)] >>> aperture = CircularAperture(positions, r=3.0) >>> phot = AperturePhotometry(data, aperture, mask=mask) >>> print(phot.flags) [8 2 5] The flag values can be decoded into human-readable names using the :meth:`~photutils.aperture.AperturePhotometry.decode_flags` convenience method, which returns a dictionary keyed by aperture position id:: >>> for source_id, names in phot.decode_flags().items(): ... print(source_id, names) 1 ['masked_pixels'] 2 ['partial_overlap'] 3 ['no_overlap', 'no_pixels'] or using the :func:`~photutils.aperture.decode_aperture_flags` function:: >>> decoded = decode_aperture_flags(phot.flags) >>> for source_id, names in zip(phot.id, decoded, strict=True): ... print(source_id, names) 1 ['masked_pixels'] 2 ['partial_overlap'] 3 ['no_overlap', 'no_pixels'] The :class:`~photutils.aperture.ApertureStats` class provides the same flags in its :attr:`~photutils.aperture.ApertureStats.flags` property (also included in the default ``to_table()`` columns), along with a :meth:`~photutils.aperture.ApertureStats.decode_flags` convenience method:: >>> from photutils.aperture import ApertureStats >>> aperstats = ApertureStats(data, aperture, mask=mask) >>> print(aperstats.flags) [8 2 5] >>> for source_id, names in aperstats.decode_flags().items(): ... print(source_id, names) 1 ['masked_pixels'] 2 ['partial_overlap'] 3 ['no_overlap', 'no_pixels'] For `~photutils.aperture.ApertureStats`, the value statistics and the sum properties are measured on two different footprints, and the single :attr:`~photutils.aperture.ApertureStats.flags` property reports the quality conditions from both. The footprint-based flags (e.g., ``'masked_pixels'``, ``'non_finite_data'``, and the overlap flags) are evaluated on the union of the ``'center'``-method footprint used by the value statistics (e.g., ``mean``, ``median``, ``std``) and the ``sum_method`` footprint used by the sum properties (``sum``, ``sum_err``, and ``sum_aper_area``). The ``'no_overlap'`` flag is set only if neither footprint overlaps the data. The ``'non_finite_error'`` flag is evaluated on the ``sum_method`` footprint, while the ``'sigma_clipped'``, ``'all_clipped'``, and ``'too_few_pixels'`` flags are evaluated on the value-statistics footprint. The ``'too_few_pixels'`` flag is set for a single valid pixel, for which the standard errors are undefined, or when ``ddof`` is greater than zero and the number of valid pixels is not larger than ``ddof``. The ``'undefined_shape'``, ``'singular_covariance'``, and ``'centroid_outside_bbox'`` flags are always evaluated. Accessing ``flags`` computes the sum, moment, and covariance quantities if they have not already been computed (the results are cached and shared with the corresponding properties), so the flag values never depend on which properties were accessed first. Because nonfinite values are automatically masked in both :class:`~photutils.aperture.AperturePhotometry` and :class:`~photutils.aperture.ApertureStats`, an aperture that contains only nonfinite values will have ``flags = 272`` (``'non_finite_data'`` + ``'all_masked'``) in :class:`~photutils.aperture.AperturePhotometry`. :class:`~photutils.aperture.ApertureStats` also sets ``'undefined_shape'``, giving ``flags = 4368``. .. _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 shape 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`, :attr:`~photutils.aperture.ApertureStats.sky_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. All properties other than the sum-related ones described below are calculated using the "center" :ref:`aperture-mask method `, which assigns aperture weights of either 0 or 1, so the ``data`` pixel values are used directly and without weighting. This choice reflects a fundamental limitation because, unlike the mean or variance, order statistics (``min``, ``max``, ``median``) and robust estimators (``mad_std``, ``biweight_location``, ``biweight_midvariance``) have no standard, unambiguous definition for pixels with fractional (partial) aperture weights. Accordingly, these quantities cannot be rigorously computed from a weighted aperture footprint. The centroid and the shape properties are calculated from image moments of the ``data`` values, including negative values. This differs from :class:`~photutils.segmentation.SourceCatalog`, which sets negative values to zero for these properties. A source segment contains only pixels above the detection threshold, whereas the sky pixels within an aperture on background-subtracted data are negative about half of the time. Including them lets the positive and negative noise cancel on average, which keeps the centroid and the shape properties nearly unbiased. For a faint source the image moments can be too noisy to define a shape. The ``'undefined_shape'`` flag is set when the net flux is not positive or when the second-order moments are not positive semidefinite, in which case the covariance-derived shape properties are NaN. The centroid of such a source is not bounded by the aperture, and the ``'centroid_outside_bbox'`` flag is set when it lies outside the aperture bounding box. The input ``sum_method`` and ``subpixels`` keywords are used to determine the aperture-mask method only for the sum-related properties: ``sum``, ``sum_err``, ``sum_aper_area``, ``data_sum_cutout``, and ``error_sum_cutout``. All other properties, including ``mean``, ``median``, ``std``, and the morphological properties, always use the "center" aperture-mask method regardless of ``sum_method``. 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. Sigma clipping is applied independently on the "center" and ``sum_method`` footprints described above, so the sum-related properties are also computed from sigma-clipped data. See :ref:`aperture_flags` for how this affects the ``flags`` property. Here is a simple example using a circular aperture at one position. Note that like :class:`~photutils.aperture.AperturePhotometry`, :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((149.97, 24.97), r=10) >>> aperstats = ApertureStats(data, aper) >>> print(aperstats.x_centroid) 150.00123266400735 >>> print(aperstats.y_centroid) 24.997448121496937 >>> print(aperstats.centroid) [150.00123266 24.99744812] >>> print(aperstats.mean, aperstats.median, aperstats.std) 27.269534935470567 11.751403764475224 36.56854575397058 >>> print(aperstats.sum) 8487.061886925028 Similar to `~photutils.aperture.AperturePhotometry`, the input aperture can have multiple positions (but the same shape and size at each position). In this case, the :class:`~photutils.aperture.ApertureStats` class will calculate the statistics for each position and return the results as arrays:: >>> aper2 = CircularAperture([(24.9, 40.0), (89.9, 60.0), (149.97, 24.97)], r=10) >>> aperstats2 = ApertureStats(data, aper2) >>> print(aperstats2.x_centroid) [ 25.08290339 89.90853538 150.00123266] >>> print(aperstats2.sum) [ 5210.72822517 34965.55786075 8487.06188693] >>> columns = ('id', 'x_centroid', 'y_centroid', '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 >>> stats_table.pprint(max_width=-1) id x_centroid y_centroid mean median std var sum --- ---------- ---------- --------- --------- --------- --------- --------- 1 25.082903 40.012769 16.694416 10.14227 19.059729 363.27325 5210.7282 2 89.908535 59.983205 111.65126 110.91821 50.389982 2539.1503 34965.558 3 150.00123 24.997448 27.269535 11.751404 36.568546 1337.2585 8487.0619 Each row of the table corresponds to a single aperture position (i.e., a single source). Background Subtraction ---------------------- Global Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :class:`~photutils.aperture.AperturePhotometry` and :class:`~photutils.aperture.ApertureStats` both assume that the input data have already been background-subtracted. If ``bkg`` is a scalar or an array representing the background of the data (e.g., estimated by `~photutils.background.Background2D` or another method), subtract it from the data before performing aperture photometry:: >>> phot = AperturePhotometry(data - bkg, aperture) # doctest: +SKIP For :class:`~photutils.aperture.ApertureStats`, a constant background can instead be specified using the ``local_bkg`` keyword. This avoids creating a temporary background-subtracted array, which can be particularly beneficial when the input data are memory-mapped. For example:: >>> aperstats = ApertureStats(data, aperture, local_bkg=bkg) # doctest: +SKIP Local Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A common approach is to estimate the local background around each source using a nearby aperture, typically an annulus surrounding the source. The :class:`~photutils.aperture.ApertureStats` class (see :ref:`photutils-aperture-stats`) can be used to compute the mean background level within such an aperture, as well as more robust statistics (e.g., a sigma-clipped median). Examples of both approaches are shown below. We begin by generating a more realistic example dataset:: >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() >>> error = 0.1 * np.ones(data.shape) # simple background-only error This artificial image has a known constant background level of 5. In the following examples, we'll leave this global background in the image so that it can be estimated from the local background around each source. We perform aperture photometry for three sources using circular apertures with a radius of 5 pixels. The local background is estimated from circular annuli with an inner radius of 10 pixels and an outer radius of 15 pixels. First, define the source and background 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) The following figure shows the source apertures (white) and background annuli (red) overlaid on a cutout 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 first use the :class:`~photutils.aperture.ApertureStats` class to compute the mean background level within the annulus at each source position:: >>> from photutils.aperture import ApertureStats >>> aperstats = ApertureStats(data, annulus_aperture) >>> bkg_mean = aperstats.mean >>> print(bkg_mean) [4.99411764 5.1349344 4.86894665] Next, we use :class:`~photutils.aperture.AperturePhotometry` to measure the source fluxes in the circular apertures:: >>> from photutils.aperture import AperturePhotometry >>> phot = AperturePhotometry(data, aperture, error=error) >>> phot_table = phot.to_table() >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux flux_err area flags pix2 --- -------- -------- --------- ---------- --------- ----- 1 145.1 168.3 1128.1245 0.8552501 78.539816 0 2 84.5 224.1 735.739 0.85778957 78.539816 0 3 48.3 200.3 1299.6341 0.85681906 78.539816 0 The total background within each source aperture is the mean background per pixel multiplied by the aperture area. Rather than the analytical aperture area, use the unmasked overlap area that was actually used for the photometry, which is available in the ``'area'`` column of the output table above (equivalent to the :meth:`~photutils.aperture.PixelAperture.area_overlap` method computed with the same photometry inputs). Using this computed area ensures the background is scaled consistently with the photometry, correctly accounting for the overlap method and any masked pixels:: >>> aperture_area = phot_table['area'].value >>> total_bkg = bkg_mean * aperture_area >>> print(total_bkg) [392.23708187 403.29680431 382.40617607] Subtracting this background estimate from the measured aperture sums gives the background-subtracted photometry:: >>> phot_bkgsub = phot_table['flux'] - total_bkg Finally, we add the total background within the aperture and the background-subtracted photometry to the output table:: >>> phot_table['total_bkg'] = total_bkg >>> phot_table['flux_bkgsub'] = phot_bkgsub >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> phot_table.pprint(max_width=-1) id x_center y_center flux flux_err area flags total_bkg flux_bkgsub pix2 --- -------- -------- --------- ---------- --------- ----- --------- ----------- 1 145.1 168.3 1128.1245 0.8552501 78.539816 0 392.23708 735.88739 2 84.5 224.1 735.739 0.85778957 78.539816 0 403.2968 332.44219 3 48.3 200.3 1299.6341 0.85681906 78.539816 0 382.40618 917.22792 Sigma-Clipped Median within a Circular Annulus """""""""""""""""""""""""""""""""""""""""""""" In this example, the local background is estimated as the sigma-clipped median within each circular annulus. We use :class:`~photutils.aperture.ApertureStats` to compute both the source photometry and the local background:: >>> 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) >>> total_bkg = bkg_stats.median * aper_stats.sum_aper_area.value >>> apersum_bkgsub = aper_stats.sum - total_bkg >>> print(apersum_bkgsub) [743.77088731 338.59823118 920.07553956] If you want to compute additional source properties on the background-subtracted data (not just :attr:`~photutils.aperture.ApertureStats.sum`), pass the per-pixel local background values to :class:`~photutils.aperture.ApertureStats` using the ``local_bkg`` keyword. The local background is then subtracted internally when computing all source properties:: >>> aper_stats_bkgsub = ApertureStats(data, aperture, ... local_bkg=bkg_stats.median) >>> print(aper_stats_bkgsub.sum) [743.77088731 338.59823118 920.07553956] The resulting aperture sums are identical to those computed above. Aperture Masks -------------- All :class:`~photutils.aperture.PixelAperture` classes provide a :meth:`~photutils.aperture.PixelAperture.to_mask` method that returns an :class:`~photutils.aperture.ApertureMask` for a single aperture position, or a list of :class:`~photutils.aperture.ApertureMask` objects when the aperture has multiple positions. Each :class:`~photutils.aperture.ApertureMask` contains the aperture mask weights together with a :class:`~photutils.aperture.BoundingBox` that defines where the mask should be applied to an image. We begin 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) Next, we create the corresponding :class:`~photutils.aperture.ApertureMask` objects using the :meth:`~photutils.aperture.PixelAperture.to_mask` method with the ``'exact'`` overlap method:: >>> masks = aperture.to_mask(method='exact') The following figure shows 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') Now create a mask using the ``'center'`` overlap method and plot the result: .. 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') The ``'center'`` method produces a binary mask, in contrast to the fractional weights returned by the ``'exact'`` method. We can also use an :class:`~photutils.aperture.ApertureMask` to create a mask-weighted cutout from the data, with partial or nonoverlapping regions handled automatically. The following example plots the first cutout using the mask generated above with the ``'exact'`` overlap method: .. 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 obtain a one-dimensional `~numpy.ndarray` containing the mask-weighted data values of the pixels with nonzero mask weight, 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 create a 2D image of the mask with a specified shape, and a :meth:`~photutils.aperture.ApertureMask.cutout` method to extract a cutout of the input data over the mask bounding box. Both methods automatically handle cases where the mask partially overlaps or lies completely outside the input data. Together, these methods provide convenient access to both the aperture mask itself and the corresponding regions of the input data. API Reference ------------- :doc:`../reference/aperture_api` astropy-photutils-24be529/docs/user_guide/asdf.rst000066400000000000000000000142621526452531300223470ustar00rootroot00000000000000.. _asdf: ASDF Serialization ================== Introduction ------------ Photutils apertures and PSF models can be written to and read from files in the `Advanced Scientific Data Format (ASDF) `_. ASDF stores the object as structured metadata in a human-readable YAML header, with any array data appended as binary blocks. Unlike :mod:`pickle`, an ASDF file is described by a published, versioned schema, so it can be inspected without Photutils and read by other implementations. Photutils registers its ASDF support through the ``asdf.extensions`` and ``asdf.resource_mappings`` entry points. No explicit imports are required to enable this support. Simply installing Photutils alongside asdf is sufficient. Requirements ------------ Serialization requires the optional ``asdf`` package. Objects that store an `~astropy.units.Quantity` or an `~astropy.coordinates.SkyCoord` additionally require the optional ``asdf-astropy`` package, which provides the converters for those Astropy types: * All PSF models require ``asdf-astropy``. * Sky apertures require ``asdf-astropy``, as do pixel apertures whose rotation angle is given as an angular quantity. * Pixel apertures whose parameters are all plain numbers require only ``asdf``. Both packages are installed by the ``all`` extra:: pip install photutils[all] If ``asdf-astropy`` is missing, PSF models raise an ``AsdfSerializationError`` when written, and apertures that store an angular quantity fail schema validation. Writing and Reading Files ------------------------- An object is written by assigning it to a key in an ``asdf.AsdfFile`` tree: .. doctest-skip:: >>> import asdf >>> from photutils.aperture import CircularAperture >>> aperture = CircularAperture((10, 20), r=5) >>> with asdf.AsdfFile() as af: ... af['aperture'] = aperture ... af.write_to('aperture.asdf') Reading returns an equivalent object: .. doctest-skip:: >>> with asdf.open('aperture.asdf') as af: ... aperture = af['aperture'] >>> aperture The tree key is arbitrary, and a tree may hold any number of objects mixed with other ASDF-serializable data: .. doctest-skip:: >>> with asdf.AsdfFile() as af: ... af['apertures'] = [aperture, other_aperture] ... af['psf'] = psf_model ... af['meta'] = {'program': 1234} ... af.write_to('photometry.asdf') Supported Objects ----------------- The pixel and sky variants of each aperture share a tag, because they differ only in whether their parameters are pixel values or angular quantities. .. list-table:: :header-rows: 1 :widths: 45 55 * - Tag - Classes * - ``photutils/aperture/circular_aperture-1.0.0`` - `~photutils.aperture.CircularAperture`, `~photutils.aperture.SkyCircularAperture` * - ``photutils/aperture/circular_annulus-1.0.0`` - `~photutils.aperture.CircularAnnulus`, `~photutils.aperture.SkyCircularAnnulus` * - ``photutils/aperture/elliptical_aperture-1.0.0`` - `~photutils.aperture.EllipticalAperture`, `~photutils.aperture.SkyEllipticalAperture` * - ``photutils/aperture/elliptical_annulus-1.0.0`` - `~photutils.aperture.EllipticalAnnulus`, `~photutils.aperture.SkyEllipticalAnnulus` * - ``photutils/aperture/polygon_aperture-1.0.0`` - `~photutils.aperture.PolygonAperture`, `~photutils.aperture.SkyPolygonAperture` * - ``photutils/aperture/rectangular_aperture-1.0.0`` - `~photutils.aperture.RectangularAperture`, `~photutils.aperture.SkyRectangularAperture` * - ``photutils/aperture/rectangular_annulus-1.0.0`` - `~photutils.aperture.RectangularAnnulus`, `~photutils.aperture.SkyRectangularAnnulus` * - ``photutils/psf/airy_disk_prf-1.0.0`` - `~photutils.psf.AiryDiskPRF` * - ``photutils/psf/airy_disk_psf-1.0.0`` - `~photutils.psf.AiryDiskPSF` * - ``photutils/psf/circular_gaussian_prf-1.0.0`` - `~photutils.psf.CircularGaussianPRF` * - ``photutils/psf/circular_gaussian_psf-1.0.0`` - `~photutils.psf.CircularGaussianPSF` * - ``photutils/psf/circular_gaussian_sigma_prf-1.0.0`` - `~photutils.psf.CircularGaussianSigmaPRF` * - ``photutils/psf/gaussian_prf-1.0.0`` - `~photutils.psf.GaussianPRF` * - ``photutils/psf/gaussian_psf-1.0.0`` - `~photutils.psf.GaussianPSF` * - ``photutils/psf/gridded_psf_model-1.0.0`` - `~photutils.psf.GriddedPSFModel` * - ``photutils/psf/image_psf-1.0.0`` - `~photutils.psf.ImagePSF` * - ``photutils/psf/moffat_prf-1.0.0`` - `~photutils.psf.MoffatPRF` * - ``photutils/psf/moffat_psf-1.0.0`` - `~photutils.psf.MoffatPSF` * - ``photutils/psf/stdpsf_grid-1.0.0`` - `~photutils.psf.STDPSFGrid` A PSF model is serialized as an Astropy transform, so its fitting state is preserved along with its parameters, including which parameters are fixed and any parameter bounds. For `~photutils.psf.GriddedPSFModel` and `~photutils.psf.STDPSFGrid`, the ePSF images, the grid positions, and the oversampling factors are stored as separate properties (`~photutils.psf.STDPSFGrid` also stores the grid shape), and any remaining metadata is stored under a ``meta`` key. Schemas and Versioning ---------------------- Each tag is backed by a published schema. The tag and schema URIs are of the form:: tag:astropy.org:photutils/aperture/circular_aperture-1.0.0 asdf://astropy.org/photutils/schemas/aperture/circular_aperture-1.0.0 and the tags are collected in the extension manifest ``asdf://astropy.org/photutils/manifests/photutils-1.0.0``. The schemas and the manifest ship inside Photutils and are registered with ``asdf`` automatically, so ``asdf`` can validate a file without any network access. Tags and schemas are versioned independently of Photutils. A change to what a tag stores requires a new tag version, and the converters for existing versions are retained, so that files written by an older version of Photutils remain readable. An ASDF file records the extension that wrote it, so ``asdf`` warns when a file is opened without that extension available. Every tagged object is validated against its schema on both write and read. astropy-photutils-24be529/docs/user_guide/background.rst000066400000000000000000000541761526452531300235610ustar00rootroot00000000000000.. _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)) 5.222396450477202 >>> print(biweight_location(data)) 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)) 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))) [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))) [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 the ``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 ``abs(mean - median) / std >= 0.3`` then the ``median`` is used instead. If the standard deviation is zero then the ``mean`` is used. 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``. .. note:: `~astropy.stats.SigmaClip` instances store internal state while clipping, so a single background or background RMS estimator instance should not be called concurrently from multiple threads when sigma clipping is enabled. After the background level has been determined in each of the boxes, the low-resolution background image can be median filtered, with a window size of ``filter_size``, to suppress local underestimates or overestimates (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 >>> norm = simple_norm(data2, 'sqrt', percent=99.5) # doctest: +SKIP >>> 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) 10.822232525276007 >>> print(round(bkg.background_rms_median, 4)) 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 >>> norm = simple_norm(data2_sub, 'sqrt', percent=99.9) >>> 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 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:: >>> 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') .. 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. Multithreading ^^^^^^^^^^^^^^ `~photutils.background.Background2D` instances are immutable after construction (aside from internal lazy-attribute caches), so a single instance is safe to share across threads and independent images can be processed concurrently, e.g., with a `~concurrent.futures.ThreadPoolExecutor`. The underlying sigma-clipping, statistics, and interpolation kernels release the Python global interpreter lock (GIL), so threads can effectively run in parallel: .. doctest-skip:: >>> from concurrent.futures import ThreadPoolExecutor >>> from photutils.background import Background2D >>> def estimate_background(data): ... return Background2D(data, (25, 25)).background >>> with ThreadPoolExecutor() as executor: ... backgrounds = list(executor.map(estimate_background, images)) In addition, the work done by `~photutils.background.Background2D` itself can be multithreaded by setting the ``n_threads`` keyword, which parallelizes both the box statistics and the interpolation of the low-resolution meshes to the full-size maps returned by the ``background`` and ``background_rms`` properties. The box statistics are identical to the single-threaded computation and the full-size maps are identical up to floating-point rounding. This is most beneficial for large images: .. doctest-skip:: >>> bkg = Background2D(data2, (15, 15), n_threads=4) Finally, when both the background and background RMS maps are needed, they can be computed concurrently (each property access recalculates its map, and the underlying interpolation releases the GIL): .. doctest-skip:: >>> with ThreadPoolExecutor(max_workers=2) as executor: ... bkg_future = executor.submit(getattr, bkg, 'background') ... rms_future = executor.submit(getattr, bkg, 'background_rms') ... bkg_image = bkg_future.result() ... rms_image = rms_future.result() 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:: >>> 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) .. 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-24be529/docs/user_guide/centroids.rst000066400000000000000000000200371526452531300234210ustar00rootroot00000000000000Centroids (`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_symmetry`: Calculates the center as the point about which the core of the source is most symmetric under a rotation by 180 degrees. * :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. Nonfinite values (e.g., NaN or inf) in the data or error arrays are automatically masked. Note that because :func:`~photutils.centroids.centroid_1dg` fits the marginal distributions of the data, a partially masked row or column near the source peak can bias its result. Consider using :func:`~photutils.centroids.centroid_2dg`, which excludes individual masked pixels from the fit, when isolated masked or nonfinite pixels fall near the source peak. 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 a 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, ... centroid_symmetry) >>> 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))) [19.9796724 20.00992593] :: >>> x2, y2 = centroid_quadratic(data) >>> print(np.array((x2, y2))) [19.94009505 20.06884997] :: >>> x3, y3 = centroid_symmetry(data) >>> print(np.array((x3, y3))) [19.98462513 20.0077986 ] :: >>> x4, y4 = centroid_1dg(data) >>> print(np.array((x4, y4))) [19.96553246 20.04952841] :: >>> x5, y5 = centroid_2dg(data) >>> print(np.array((x5, y5))) [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. The small offsets from the true centroid are caused by the noise in the image. They are in a similar direction for each method because all of the centroids are measured from the same noisy pixels. Now let's plot the results. Because the centroids are all very similar, we also include an inset plot that zooms in on a region 0.1 pixels wide near the centroids: .. plot:: import matplotlib.pyplot as plt import numpy as np from matplotlib import patheffects from photutils.centroids import (centroid_1dg, centroid_2dg, centroid_com, centroid_quadratic, centroid_symmetry) 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_symmetry(data) xycen4 = centroid_1dg(data) xycen5 = centroid_2dg(data) xycens = [xycen1, xycen2, xycen3, xycen4, xycen5] fig, ax = plt.subplots(figsize=(8, 8)) ax.imshow(data, origin='lower') marker = '+' ms = 60 colors = ('white', 'cyan', 'magenta', 'red', 'orange') labels = ('Center of Mass', 'Quadratic', 'Symmetry', '1D Gaussian', '2D Gaussian') for xycen, color, label in zip(xycens, colors, labels, strict=True): ax.scatter(*xycen, color=color, marker=marker, s=ms, label=label) ax.legend(loc='lower right', fontsize=12, facecolor='0.2', labelcolor='white') ax.set_xlim(0, data.shape[1] - 1) ax.set_ylim(0, data.shape[0] - 1) # The inset has a plain dark background because it lies within a # single pixel of the image ax2 = ax.inset_axes([0.3, 0.6, 0.38, 0.38], facecolor='0.2') ms = 300 for xycen, color in zip(xycens, colors, strict=True): ax2.scatter(*xycen, color=color, marker=marker, s=ms) ax2.set_xlim(19.91, 20.01) ax2.set_ylim(19.99, 20.09) ax2.set_aspect('equal') ax2.tick_params(colors='white') pe_col = 'darkgray' for ticklabel in ax2.get_xticklabels() + ax2.get_yticklabels(): ticklabel.set_path_effects([patheffects.withStroke(linewidth=1.5, foreground=pe_col)]) ax.indicate_inset_zoom(ax2, edgecolor='gray', alpha=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 nonrectangular 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) [ 24.96807828 89.98684636 149.96545721 160.18810915] >>> print(y) [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-24be529/docs/user_guide/curves_of_growth.rst000066400000000000000000000742751526452531300250310ustar00rootroot00000000000000.. _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) [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) [ 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) [ 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) [ 3.11062819 6.83127792 10.55630757 14.27852358 18.00330858 21.72296997 25.44442261 29.16767174 32.88949473 36.61224389 40.33235754 44.0555786 47.77789677 51.49947445 55.22141896 58.94275538 62.66630873 66.38766913 70.10917057 73.83146673 77.55360137 81.27674225 84.99747377 88.71963388 92.44198203] Normalization ^^^^^^^^^^^^^ Typically, the normalized curve of growth is of interest, where the profile is scaled so that its maximum value is 1. 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 the sum of its values 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 so that its maximum value is 1. 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). See :ref:`noisy_curve_of_growth` for how these methods handle a profile that is not monotonically increasing. 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 array([0.42698425, 0.66439702, 0.89047904, 0.99342893]) >>> cog.calc_radius_at_ee(ee_vals) 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, strict=True): ax.text(ee_rad/2, ee_val, f'{ee_val:.2f}', ha='center', va='bottom') .. _noisy_curve_of_growth: Noisy Curves of Growth ^^^^^^^^^^^^^^^^^^^^^^ The true curve of growth of a source never decreases with radius, but a measured one can. Where the source flux has leveled off, the noise in each additional annulus can be larger than the flux it adds, so the profile moves up and down. The profile we created above decreases for the first time between radii of 17 and 18 pixels:: >>> bool(np.all(np.diff(cog.profile) > 0)) False >>> print(cog.radius[:-1][np.diff(cog.profile) <= 0]) [17 19 22 24] :meth:`~photutils.profiles.CurveOfGrowth.calc_ee_at_radius` interpolates the measured profile, so the encircled energy it returns can also decrease with radius. The same encircled energy can then occur at more than one radius. :meth:`~photutils.profiles.CurveOfGrowth.calc_radius_at_ee` returns the smallest radius at which the interpolated curve reaches the given encircled energy. Every encircled energy between the smallest and the largest profile value has a radius, and converting that radius back gives the same encircled energy:: >>> ee_vals = np.array([0.5, 0.9, 0.99, 0.999, 1.0]) >>> ee_rads = cog.calc_radius_at_ee(ee_vals) >>> print(ee_rads) # doctest: +FLOAT_CMP [ 5.58334451 10.20639775 14.38357656 21.89837891 24. ] >>> print(cog.calc_ee_at_radius(ee_rads)) # doctest: +FLOAT_CMP [0.5 0.9 0.99 0.999 1. ] The reverse is not true after the profile has decreased. The encircled energy at a radius of 19 pixels was already reached at a smaller radius, which is the one returned:: >>> ee_val = cog.calc_ee_at_radius(19.0) >>> print(cog.calc_radius_at_ee(ee_val)) # doctest: +FLOAT_CMP 15.57818537 .. note:: Where a noisy profile has leveled off, the radius of a given encircled energy is poorly constrained by the data. In addition, the maximum of a noisy profile is larger than the true total flux, so a profile normalized with :meth:`~photutils.profiles.CurveOfGrowth.normalize` reaches each encircled energy at too large a radius. For the example above, the radius at an encircled energy of 0.99 is typically overestimated by about 1 pixel, with a scatter of about 2 pixels between noise realizations. If you need the radius at a large encircled energy (e.g., 0.95 or more), use a profile with a high signal-to-noise ratio. If the total flux of the source is known (e.g., from a model or a catalog), divide the input ``data`` by that total flux instead of calling :meth:`~photutils.profiles.CurveOfGrowth.normalize`. The radii are then much less biased and less scattered, but :meth:`~photutils.profiles.CurveOfGrowth.calc_radius_at_ee` returns NaN for an encircled energy that the measured profile never reaches. Here we plot the outer part of the normalized profile, the curve interpolated by :meth:`~photutils.profiles.CurveOfGrowth.calc_ee_at_radius`, and the radius returned for an encircled energy of 0.995. .. 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 # Find the source centroid xycen = centroid_2dg(data) # Create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii) cog.normalize(method='max') # Evaluate the interpolated curve on a fine grid fine_radii = np.linspace(12, 25, 500) ee_vals = cog.calc_ee_at_radius(fine_radii) # The radius at an encircled energy of 0.995 ee_val = 0.995 ee_rad = cog.calc_radius_at_ee(ee_val) fig, ax = plt.subplots(figsize=(8, 6)) ax.plot(cog.radius, cog.profile, 'o', label='Measured profile') ax.plot(fine_radii, ee_vals, label='Interpolated curve') ax.axhline(ee_val, color='C2', ls='dashed', label=f'EE = {ee_val}') ax.plot(ee_rad, ee_val, 'o', color='C3', ms=10, label=f'Returned radius = {ee_rad:.2f}') ax.set_xlim(12, 25.5) ax.set_ylim(0.97, 1.002) ax.set_xlabel('Radius (pixels)') ax.set_ylabel('Encircled Energy') ax.legend(loc='lower right') The `~photutils.profiles.EnsquaredCurveOfGrowth` and `~photutils.profiles.EllipticalCurveOfGrowth` classes described below handle noisy profiles in the same way. 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) [ 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) [ 171.51682931 640.93965938 1328.92447125 2143.49339746 2954.67817356 3718.43574763 4357.67409109 4845.38866928 5199.93124392 5481.4874771 5641.60852855 5752.43870109 5791.01446371 5819.46039533 5832.44691816 5825.39056549 5834.14890108 5833.42003487 5852.25143102 5856.59671815 5869.46227464 5872.94734439 5868.92606685 5849.96903786 5839.55875017] >>> print(ecog.profile_error) [ 3.57264883 7.77582626 11.97677454 16.17723038 20.3774983 24.57767465 28.77779953 32.97789259 37.17796463 41.37802205 45.57806888 49.77810781 53.97814068 58.17816881 62.37819315 66.57821441 70.77823316 74.9782498 79.17826468 83.37827806 87.57829015 91.77830114 95.97831117 100.17832036 104.3783288 ] 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 array([0.22627897, 0.63314645, 0.88540403]) >>> ecog.calc_half_size_at_ee(ee_vals) 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, strict=True): 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) [ 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) [ 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) [ 1.82558544 4.4811361 7.21875141 9.88960965 12.44181885 15.0767893 17.74361162 20.3959057 23.02040853 25.60886742 28.25910457 30.93376937 33.54721095 36.14973269 38.78350009 41.44751786 44.08722476 46.67911616 49.30783947 51.9760285 54.61090354 57.21854088 59.84454217 62.48828381 65.14297876 67.7591664 70.36843954 73.01750591 75.66115523 78.29244888 80.90838345 83.53056346 86.19047387 88.82463241 91.43141788 94.06294498 96.71101089 99.35484622 101.96990083] 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 array([0.13260338, 0.43222011, 0.72178335, 0.89835564]) >>> ecog.calc_radius_at_ee(ee_vals) 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, strict=True): ax.text(ee_rad/2, ee_val, f'{ee_val:.2f}', ha='center', va='bottom') API Reference ------------- :doc:`../reference/profiles_api` astropy-photutils-24be529/docs/user_guide/datasets.rst000066400000000000000000000107451526452531300232440ustar00rootroot00000000000000.. _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_*`` download example datasets from a remote repository. On first load, these datasets are downloaded and placed into the Astropy cache on the user's machine. These functions are deprecated and will be removed in version 4.0. 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 from 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-photutils-24be529/docs/user_guide/detection.rst000066400000000000000000000323461526452531300234130ustar00rootroot00000000000000.. _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 ``roundness1``, ``roundness2``, and ``daofind_mag`` columns, while :class:`~photutils.detection.IRAFStarFinder` includes ``fwhm`` and ``orientation`` 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.io import fits >>> from astropy.stats import sigma_clipped_stats >>> from photutils.datasets import make_noise_image >>> url = ('https://github.com/astropy/photutils-datasets/raw/main/' ... 'data/hst_wfc3ir_f160w_simulated_starfield.fits') >>> data = fits.getdata(url) # doctest: +REMOTE_DATA >>> data += make_noise_image(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 [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, using a Gaussian kernel with a FWHM of 2.5 pixels, that 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. First, we create the star finder. Calling it on the data returns 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 id x_centroid y_centroid sharpness ... peak flux mag daofind_mag --- ---------- ---------- --------- ... ------- ------- ----- ----------- 1 848.57 2.15 0.89 ... 1051.78 3391.76 -8.83 -3.80 2 181.85 3.75 0.97 ... 1711.87 4823.64 -9.21 -4.28 3 323.88 3.70 0.96 ... 3005.97 8627.30 -9.84 -4.90 4 99.89 8.95 1.07 ... 1134.12 2789.54 -8.61 -3.77 ... ... ... ... ... ... ... ... ... 497 114.16 993.47 0.84 ... 1577.91 5697.73 -9.39 -4.26 498 298.44 993.87 0.83 ... 644.97 2282.34 -8.40 -3.31 499 207.21 998.15 0.97 ... 2800.62 7693.48 -9.72 -4.83 500 691.03 998.77 1.15 ... 2600.83 5132.75 -9.28 -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.io import fits from astropy.stats import sigma_clipped_stats from astropy.visualization import simple_norm from photutils.aperture import CircularAperture from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) data += make_noise_image(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.io import fits from astropy.stats import sigma_clipped_stats from astropy.visualization import simple_norm from photutils.aperture import CircularAperture, RectangularAperture from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) data += make_noise_image(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 be separated by at least ``box_size // 2 + 1`` pixels along at least one axis. For example, with a ``box_size`` of 11, two peaks must be at least 6 pixels apart along the x- or y-axis. 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 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.io import fits from astropy.stats import sigma_clipped_stats from astropy.visualization import simple_norm from photutils.aperture import CircularAperture from photutils.datasets import make_noise_image from photutils.detection import find_peaks url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) data += make_noise_image(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-24be529/docs/user_guide/epsf_building.rst000066400000000000000000001673561526452531300242610ustar00rootroot00000000000000.. _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 may be oversampled with respect to the detector pixels. Oversampling matters when the PSF is undersampled by the detector, e.g., a FWHM of only one or two pixels. Since stars can land at fractional pixel positions on the detector, the appearance of such a PSF varies with the star's position within a pixel, and an oversampled ePSF captures this pixel-phase variation so that the PSF can be interpolated to the exact position of any star. When the PSF is well sampled (a FWHM of about 4 pixels or more), an ePSF with no oversampling already captures its shape, and a larger oversampling factor improves it only slightly (see :ref:`epsf-guidelines`). Building an ePSF ---------------- Photutils provides tools for building an ePSF that are based on the method of `Anderson and King 2000 (PASP 112, 1360) `_ and `Anderson 2016 (WFC3 ISR 2016-12) `_. The implementation differs from that method in several steps, so that it can be used for other instruments, samplings, and oversampling factors (see :ref:`epsf-anderson-differences`). 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., a few hundred) in order to sample the PSF at all subpixel phases and to help reduce the effects of noise. Otherwise, the resulting ePSF may be noisy or biased. See :ref:`epsf-guidelines` for guidance on choosing the oversampling factor and the star sample. Let's start by loading a simulated HST/WFC3 image in the F160W band:: >>> from astropy.io import fits >>> url = ('https://github.com/astropy/photutils-datasets/raw/main/' ... 'data/hst_wfc3ir_f160w_simulated_starfield.fits') >>> data = fits.getdata(url) # 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:: import matplotlib.pyplot as plt from astropy.io import fits from astropy.visualization import simple_norm from photutils.datasets import make_noise_image url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) 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 3526.92 -8.87 -2.41 2 181.85 3.74 0.91 ... 1722.27 4958.81 -9.24 -2.93 3 323.87 3.69 0.91 ... 3016.37 8762.46 -9.86 -3.55 4 99.89 8.95 0.96 ... 1144.52 2924.70 -8.67 -2.47 5 824.12 9.36 0.90 ... 1311.20 3946.46 -8.99 -2.64 ... ... ... ... ... ... ... ... ... 478 888.44 991.86 0.85 ... 194.27 771.54 -7.22 -0.52 479 114.16 993.40 0.84 ... 1588.31 5832.89 -9.41 -2.84 480 298.36 993.87 0.84 ... 655.37 2417.51 -8.46 -1.88 481 207.21 998.17 0.91 ... 2811.02 7818.24 -9.73 -3.48 482 691.02 998.77 0.98 ... 2611.22 5226.33 -9.30 -3.39 Length = 482 rows Let's show the detected stars overlaid on the image: .. plot:: import matplotlib.pyplot as plt from astropy.io import fits from astropy.visualization import simple_norm from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) 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 25x25 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`). The background level must be measured from pixels that are free of star light. The extended wings of the stars cover a large fraction of this image, and sigma clipping does not remove them. The median of the whole image is therefore biased high by about 0.35 counts. That is a small fraction of the noise, but summed over a 25x25 pixel cutout it is about 3% of the flux of a typical star in this image, and subtracting it would make the ePSF too concentrated. To avoid this bias, we first mask the pixels within 18 pixels of each detected star:: >>> import numpy as np >>> from photutils.utils import circular_footprint >>> from scipy.ndimage import binary_dilation >>> star_mask = np.zeros(data.shape, dtype=bool) # doctest: +REMOTE_DATA >>> yidx = np.round(sources['y_centroid']).astype(int) # doctest: +REMOTE_DATA >>> xidx = np.round(sources['x_centroid']).astype(int) # doctest: +REMOTE_DATA >>> star_mask[yidx, xidx] = True # doctest: +REMOTE_DATA >>> star_mask = binary_dilation( ... star_mask, structure=circular_footprint(18)) # doctest: +REMOTE_DATA Now let's subtract the background, measured from the unmasked pixels, from the image:: >>> from astropy.stats import sigma_clipped_stats >>> mean_val, median_val, std_val = sigma_clipped_stats( ... data, sigma=2.0, mask=star_mask) # doctest: +REMOTE_DATA >>> data -= median_val # doctest: +REMOTE_DATA We are now ready to create our star cutouts using the :func:`~photutils.psf.extract_stars` function. The input image can be a plain 2D array, as in this example, or an `~astropy.nddata.NDData` object. An `~astropy.nddata.NDData` object is needed to input an uncertainty, a mask, or a WCS along with the image. 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 images must be `~astropy.nddata.NDData` objects that 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 and, by default, the same flux in each input image (see :ref:`epsf-linked-stars`). Let's extract the 25x25 pixel cutouts of our selected stars:: >>> from photutils.psf import extract_stars >>> stars = extract_stars(data, 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 import numpy as np from astropy.io import fits from astropy.stats import sigma_clipped_stats from astropy.table import Table from astropy.visualization import simple_norm from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder from photutils.psf import extract_stars from photutils.utils import circular_footprint from scipy.ndimage import binary_dilation url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) 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] star_mask = np.zeros(data.shape, dtype=bool) yidx = np.round(sources['y_centroid']).astype(int) xidx = np.round(sources['x_centroid']).astype(int) star_mask[yidx, xidx] = True star_mask = binary_dilation(star_mask, structure=circular_footprint(18)) mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0, mask=star_mask) data -= median_val stars = extract_stars(data, 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, which is appropriate for these undersampled stars (a FWHM of about 1.5 pixels). We use the default maximum of 10 iterations (``maxiters=10``). The build stops early once the star centers have converged. Do not stop the build after only a few iterations. An ePSF that has not converged can differ from the true ePSF by a few percent of its peak, and the fitted star positions are less accurate. The :class:`~photutils.psf.EPSFBuilder` class has many options to control the ePSF build process, including the smoothing kernel, the fitting box, the recentering function, and the convergence criterion. 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, ... progress_bar=False) # doctest: +REMOTE_DATA >>> result = epsf_builder(stars) # doctest: +REMOTE_DATA The :class:`~photutils.psf.EPSFBuilder` returns an `~photutils.psf.EPSFBuildResults` 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:: >>> # Access result attributes >>> epsf = result.epsf # doctest: +REMOTE_DATA >>> fitted_stars = result.fitted_stars # doctest: +REMOTE_DATA >>> # Tuple unpacking also works >>> epsf, fitted_stars = result # doctest: +REMOTE_DATA The `~photutils.psf.EPSFBuildResults` object provides useful diagnostic information about the build process:: >>> result.converged # doctest: +REMOTE_DATA True >>> result.iterations # doctest: +REMOTE_DATA 10 >>> result.n_excluded_stars # doctest: +REMOTE_DATA 0 The results also report the fraction of stars whose centers converged (``final_converged_fraction``), the largest center movement in the final iteration (``final_center_accuracy``), and the smoothing kernel and fitting box that were used (``smoothing_kernel`` and ``fit_shape``). See `~photutils.psf.EPSFBuildResults` for the full list. The results also keep the ePSF image after each iteration in the ``iteration_epsfs`` attribute, and the ``iteration_info`` attribute is a table with the convergence statistics of each iteration. The ``max_epsf_change`` column is the largest change of the ePSF from the previous iteration as a fraction of its peak. If it is still large in the last building iteration, increase ``maxiters``. The ``converged`` column tells whether the star centers had converged in each iteration, so the last ``'build'`` row tells whether the building iterations converged. The center changes are measured relative to their median change, and ``max_center_shift`` is the largest of them. The build has converged when the ``converged_fraction`` of the builder (95% by default) of these relative changes and the median change itself (``common_center_shift``) are less than ``center_accuracy``. The ``plot_iterations`` method plots the ePSF after each iteration and its change from the previous iteration:: >>> result.iteration_info['iteration', 'stage', 'max_epsf_change'].pprint(max_lines=6) # doctest: +SKIP >>> fig = result.plot_iterations() # doctest: +SKIP 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 import numpy as np from astropy.io import fits from astropy.stats import sigma_clipped_stats from astropy.table import Table from astropy.visualization import simple_norm from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder from photutils.psf import EPSFBuilder, extract_stars from photutils.utils import circular_footprint from scipy.ndimage import binary_dilation url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) 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] star_mask = np.zeros(data.shape, dtype=bool) yidx = np.round(sources['y_centroid']).astype(int) xidx = np.round(sources['x_centroid']).astype(int) star_mask[yidx, xidx] = True star_mask = binary_dilation(star_mask, structure=circular_footprint(18)) mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0, mask=star_mask) data -= median_val stars = extract_stars(data, stars_tbl, size=25) epsf_builder = EPSFBuilder(oversampling=4, 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 star sample is small or noisy. The smoothing kernels are least-squares polynomial smoothers. Each grid value is replaced by the value at the center of a polynomial fit to the surrounding grid values, which removes noise while preserving the polynomial shape of the ePSF within the kernel window. The default is ``'auto'``, which uses a quartic (fourth-degree) polynomial kernel whose width is 0.7 times the FWHM of the ePSF in oversampled grid points, measured in each iteration along its narrowest axis. The width is rounded down to an odd number of grid points, and no smoothing is applied when it would be smaller than 5 grid points, i.e., for undersampled ePSFs with fewer than about 7 grid points per FWHM, where a fixed 5x5 kernel would lower the peak of the ePSF. The kernel is square, so with anisotropic oversampling the axis with the fewer grid points per FWHM sets its size. The chosen kernel is reported in the ``smoothing_kernel`` attribute of the results, and it can be input as a fixed ``smoothing_kernel`` to reproduce the build. If the FWHM cannot be measured, the ``'quartic'`` kernel is used and a warning is emitted. You can also use ``'quartic'`` for the fixed 5x5 fourth-degree polynomial kernel of `Anderson and King 2000 (PASP 112, 1360) `_, ``'quadratic'`` for its second-degree counterpart, provide a custom 2D array, or set it to `None` for no smoothing:: >>> epsf_builder = EPSFBuilder(oversampling=4, ... smoothing_kernel='quadratic', ... progress_bar=False) # doctest: +REMOTE_DATA The fixed kernels are applied on the oversampled grid, so their physical width is ``5 / oversampling`` detector pixels. The 5x5 quartic kernel was developed for HST data with an oversampling factor of 4, where it is about 0.7 FWHM wide. When using a fixed kernel for an undersampled ePSF with fewer than about 7 grid points per FWHM, the kernel lowers the peak of the ePSF, and ``smoothing_kernel=None`` is a better choice, especially when the stars have high signal-to-noise. Smoothing is most useful for well-sampled ePSFs built from noisy or few stars. The wings of the ePSF are smoothed separately. Far from its center the ePSF is faint and varies slowly, so its noise can be averaged over a larger area than in the core. After the last iteration, each value of the ePSF beyond 3.5 FWHM from the center is blended into a least-squares quadratic fit to the values in a box 1.25 FWHM wide around it, and beyond 5 FWHM into the fit in a box 1.75 FWHM wide. The ePSF within 3.5 FWHM of the center changes only by the renormalization of the smoothed ePSF (less than 0.03 percent in tests). The fluxes of the returned stars were fit before that renormalization. This lowers the noise of the wings, which matters when they are used, e.g., to subtract bright stars, to make model images, or to measure encircled energies. The smoothing also removes real structure in the wings that is finer than about two FWHM, such as diffraction rings and spikes. Applied to noise-free ePSFs, it changed the wings by 2 to 8 percent of their mean value for JWST and Roman ePSFs and by 11 to 14 percent for HST WFC3/IR ePSFs. With a few hundred stars the noise that it removes is larger than this in every case that was tested (the residuals of the wings were up to about 50 percent lower). For a large star sample of high signal-to-noise (thousands of stars), the noise in the wings can be smaller than this change, and the wings are then more accurate without the smoothing. The boxes are at least 5 oversampled grid points wide. For an ePSF with a FWHM of less than 4 grid points they are therefore wider than given above, and they remove more of the real structure. This matters most for an oversampling factor of 1, where the boxes of an undersampled ePSF (a FWHM of about 1.3 pixels) are nearly 4 FWHM wide. In tests with an oversampling factor of 1, the smoothing made the wings of the most undersampled HST and JWST ePSFs less accurate, by up to a factor of about 2, and those of most other ePSFs slightly more accurate. With an oversampling factor of 2 or larger it made the wings more accurate or left them unchanged in every case. For an oversampling factor of 1, compare the ePSFs built with and without the smoothing. The smoothing is applied after the last iteration, so it is not part of ``iteration_epsfs`` or ``iteration_info``. The ``plot_iterations`` method shows the smoothed ePSF and the change made by the smoothing in a last row of its figure when all the iterations are plotted. Set ``show_smoothed=True`` to also plot that row with a subset of the iterations, or ``show_smoothed=False`` to never plot it. Set ``wing_smoothing=False`` to keep the wings as built:: >>> epsf_builder = EPSFBuilder(oversampling=4, wing_smoothing=False, ... progress_bar=False) # doctest: +REMOTE_DATA .. _epsf-alias-passband: Alias Filter Passband ^^^^^^^^^^^^^^^^^^^^^ Independently of the smoothing kernel, when the oversampling factor is greater than one the builder applies a low-pass filter to the ePSF in every iteration. The filter has unit gain up to a passband frequency, a smooth transition, and zero gain at and above one cycle per detector pixel. Here a spatial frequency of one cycle per pixel describes structure that repeats with a period of one detector pixel, and a frequency of 0.5 cycles per pixel describes structure that repeats every two pixels. A pixel-integrated PSF has essentially no signal at one cycle per pixel, but that is the frequency at which the pixel sampling of the stars aliases onto the oversampled grid. Together with depositing each star pixel residual on the oversampled grid points within 0.375 pixel of the pixel center along each axis, the filter prevents noise from heterogeneous, contaminated, or low signal-to-noise stars from growing into a checkerboard pattern in the ePSF. The ``alias_passband`` parameter sets the end of the passband in cycles per detector pixel. The default (``'auto'``) is 0.8 cycles per pixel, or 0.7 for an oversampling factor of 2. The default is the best choice for most data. A different value can help in two cases, which depend on how much real signal the ePSF has just below one cycle per pixel. That is set by the optical cutoff frequency of the telescope expressed in cycles per pixel: .. math:: \nu_c = \frac{D \, p}{\lambda} where :math:`D` is the telescope diameter, :math:`\lambda` is the mean wavelength of the bandpass (in the same units as :math:`D`), and :math:`p` is the pixel scale in radians per pixel. A telescope transmits no signal above this frequency. For example, for HST (:math:`D = 2.4` m) WFC3/IR (0.13 arcsec per pixel) at 1.1 microns, :math:`\nu_c = (2.4 \times 6.3 \times 10^{-7}) / (1.1 \times 10^{-6}) = 1.4` cycles per pixel. .. list-table:: :header-rows: 1 :widths: 22 33 45 * - :math:`\nu_c` (cycles/pixel) - Examples - Guidance for ``alias_passband`` * - greater than about 1 - HST WFC3/IR F110W, JWST NIRCam F070W, JWST NIRISS F090W, Roman WFI F062 and F106 - The default, or 0.85 with more iterations. The ePSF has real signal up to nearly one cycle per pixel. In tests the default recovered the peak of these ePSFs to within about 1 percent, except for HST WFC3/IR F110W (3 percent low). A value of 0.85 with a ``maxiters`` of 15 to 20 recovered the peaks to within 0.3 percent and lowered the largest residual in the core by 10 to 35 percent. For F110W it left the peak 1.5 percent low, and 0.9 recovered it to within 0.3 percent. For the others 0.9 gave larger residuals than 0.85. * - about 0.9 to 1 - HST WFC3/IR F160W - The default (0.8) * - less than about 0.9 - JWST NIRCam F115W and redder, JWST MIRI, Roman WFI F158 and F213, most ground-based data - The default, or 0.7. There is no signal to preserve near one cycle per pixel. In tests 0.7 lowered the residuals in the core by 10 to 40 percent and converged in fewer iterations. For a strongly undersampled detector, first check whether the stars were observed in several dithered exposures. If they were, link the stars across the exposures and keep the default passband (see :ref:`epsf-linked-stars`). The peak of a strongly undersampled ePSF is low mainly because the fitted centers and fluxes of the individual stars are inaccurate, and averaging them over the exposures corrects that more effectively than a wider passband does. If only one exposure is available, try 0.85 with more iterations for a strongly undersampled detector whose cutoff frequency is well above one cycle per pixel:: >>> epsf_builder = EPSFBuilder(oversampling=4, alias_passband=0.85, ... maxiters=20, ... progress_bar=False) # doctest: +REMOTE_DATA Try 0.9 only if that ePSF is still too broad, i.e., if the stars have positive residuals at their centers after the fitted ePSF is subtracted. Do not use 0.7 unless the cutoff frequency is known to be low. It leaves the peak of a strongly undersampled ePSF low by 2 to 6 percent. A passband that is wider than needed has a cost. A star that is sampled once per pixel constrains the frequencies near one cycle per pixel only weakly, because a small shift of the star center has nearly the same effect on its pixel values. A wider passband therefore makes the build converge more slowly and makes it more sensitive to noise. With the default ``maxiters`` of 10, the peak of an ePSF built with a passband of 0.85 or 0.9 is too high by up to about 1 percent. Use a passband above the default only with a large star sample (a few hundred stars) and an oversampling factor of 4 or more, allow more iterations (``maxiters`` of 15 to 20), and check that the build converged. Do not use it with an oversampling factor of 2 or 3. In tests with stars of different shapes at those factors, the additional iterations biased the fitted star centers toward particular subpixel phases, and at a factor of 3 some builds grew a checkerboard pattern. The filter acts separately along the x- and y-axes. The signal that it removes from an undersampled ePSF therefore shows as a faint ripple pattern, with a period of about one pixel, along the row and the column through the center of the ePSF. To remove this pattern, the builder refines the ePSF after the building iterations. Each refinement iteration (``refinement_iters``, 5 by default) updates the ePSF five times with the star centers and fluxes held fixed and then refits the stars with the updated ePSF. The ePSF is recentered in each update, as in the building iterations. These updates use a wider and smoother low-pass filter, with unit gain up to 1.1 cycles per pixel and zero gain at and above 1.33 cycles per pixel. It does not remove signal near one cycle per pixel, so it leaves no ripple pattern. It removes only the frequencies that the star residuals do not constrain. Such a filter cannot be used from the start of the build, because the build then converges slowly and is more sensitive to the initial star centers. The refinement is applied only for an oversampling factor of 4 or larger, and it roughly doubles the run time of the build. For a well-sampled ePSF it has little to restore and it adds a small amount of noise (up to about 15 percent of the residual of the ePSF). Set ``refinement_iters=0`` to skip it. More refinement iterations than the default improve the most strongly undersampled ePSFs only slightly and add more noise to the well-sampled ones. The ``converged``, ``final_center_accuracy``, and ``final_converged_fraction`` attributes of the results describe the last refinement iteration when the ePSF was refined, so that they match the returned stars. The ``iterations`` attribute counts only the building iterations. Setting ``alias_passband=None`` turns the filter off. This is rarely appropriate. Without the filter, noise at the alias frequencies accumulates over the iterations, the build can stall before it converges, and heterogeneous or contaminated star samples can grow a checkerboard pattern. In tests with simulated HST, JWST, and Roman star fields, the unfiltered ePSF was less accurate than the filtered one in nearly every case, even for large, clean, and homogeneous star samples. The option is provided for experimentation, e.g., to check how much the filter changes a particular ePSF. Always compare the result with a filtered build. The filter is never applied along an axis with an oversampling factor of 1. If the subpixel phases of the fitted star centers are strongly nonuniform at the end of the build, which indicates biased star centers, a warning is emitted. In that case the star sample should be inspected for stars with different PSFs, saturated or contaminated cutouts, or spurious detections. .. _epsf-linked-stars: Linked Stars from Dithered Images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When the same star is observed in several dithered images, the cutouts can be linked as a `~photutils.psf.LinkedEPSFStar` (this happens automatically when :func:`~photutils.psf.extract_stars` is given multiple images and a single catalog of sky coordinates). After each fitting iteration, the builder constrains the centers of the linked stars to a single sky coordinate and, by default, their fluxes to their mean value. Averaging the positions and the fluxes across dithers is the key step of `Anderson and King 2000 (PASP 112, 1360) `_ that breaks the degeneracy between the shape of the ePSF and the positions of the stars. `Godden and Blundell 2026 (RASTI 5, 1) `_ confirmed that constraining the positions alone is not enough. The fluxes must also be constrained to break the degeneracy between the flux of a star and its subpixel position caused by intra-pixel sensitivity variations. Without it, the pixel-phase dependence of the individual flux measurements is absorbed into the ePSF. The flux constraint assumes that the linked images have the same flux scale (e.g., the same exposure time and throughput). If they do not, set ``constrain_fluxes=False``:: >>> epsf_builder = EPSFBuilder(oversampling=4, ... constrain_fluxes=False, ... progress_bar=False) # doctest: +REMOTE_DATA Linked stars are the recommended way to build the ePSF of an undersampled detector. The center and the flux of a single undersampled star are poorly constrained, and their errors bias the ePSF, most visibly by lowering its peak. In tests with simulated HST, JWST, and Roman star fields (about 450 stars, an oversampling factor of 4, and the default builder settings), linking the stars of four dithered exposures recovered the peak of every ePSF to within 0.4 percent, including the HST WFC3/IR F110W ePSF, whose peak was about 3 percent low when built from one exposure. The residuals in the core of the ePSFs were about one third of those from one exposure, the errors of the fitted star positions were about half, and the builds converged in about half as many iterations. Two linked exposures recovered the peaks to within 0.7 percent. With ``constrain_fluxes=False`` the peaks were recovered only to within about 1 percent. The improvement comes from the constraints and not from the additional star cutouts. In the same tests, the cutouts of the four exposures used as unlinked stars gave strongly undersampled ePSFs that were no more accurate than those from one exposure. The stars of dithered exposures should therefore always be linked. In those tests the WCS of each exposure was exact and the exposures had the same PSF and flux scale. The constraints are only as accurate as the relative alignment of the images, so align the WCS of the exposures before linking the stars. When the tests with four exposures were repeated with random shifts added to the WCS of each exposure, the residuals in the core of the ePSFs were about 0.4, 0.6, and 0.9 times those from one exposure for shifts with a standard deviation of 0.005, 0.01, and 0.02 pixel along each axis. With shifts of 0.05 pixel the residuals were nearly twice those from one exposure. Rotation and scale errors of the WCS, which displace the stars by an amount that varies across the image, increased the residuals about half as much as shifts that displace the stars by the same amount on average. The exposures should therefore be aligned to about 0.01 pixel or better over the whole image. The fitted position of each star is also shifted by the alignment error of its exposure. To link stars across images, provide a single catalog with sky coordinates and multiple `~astropy.nddata.NDData` objects, each with a valid WCS: .. doctest-skip:: >>> import astropy.units as u >>> from astropy.coordinates import SkyCoord >>> catalog = Table() >>> catalog['skycoord'] = SkyCoord(ra=[...]*u.deg, dec=[...]*u.deg) >>> stars = extract_stars([nddata1, nddata2], catalog, size=25) 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 detector pixels) centered on each star used for fitting. The default is ``'auto'``, which uses a square box of twice the FWHM of the ePSF in detector pixels (measured in each iteration along its narrowest axis), with a minimum of 5 pixels and a maximum of the star cutout size. The chosen box is reported in the ``fit_shape`` attribute of the results. A fixed box can be given instead. A smaller box speeds up the fitting, but it should still cover the core of the star. A box that is much smaller than the star uses only its flat core, which biases the fitted centers and can prevent the build from converging. The 5-pixel box of Anderson 2016 is about 2.5 FWHM wide for HST data but only about 1 FWHM wide for a star with a FWHM of 5 pixels:: >>> epsf_builder = EPSFBuilder(oversampling=4, ... 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, ... 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, ... sigma_clip=sigclip, ... progress_bar=False) # doctest: +REMOTE_DATA Setting ``sigma_clip=None`` disables sigma clipping entirely. 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`):: >>> import numpy as np >>> from astropy.nddata import NDData, StdDevUncertainty >>> uncertainty = StdDevUncertainty(np.sqrt(np.abs(data))) # doctest: +REMOTE_DATA, +SKIP >>> nddata = NDData(data=data, uncertainty=uncertainty) # doctest: +REMOTE_DATA, +SKIP .. _epsf-guidelines: Guidelines for Building a Good ePSF ----------------------------------- The quality of an ePSF depends more on the input stars and on a sensible choice of the oversampling factor than on the other builder parameters. The following guidelines are based on `Anderson and King 2000 (PASP 112, 1360) `_ and on the systematic tests of `Godden and Blundell 2026 (RASTI 5, 1) `_, with the oversampling and star-count advice updated from tests of the current :class:`~photutils.psf.EPSFBuilder` implementation. Choosing the Oversampling Factor ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ePSF is tabulated on a grid with a spacing of ``1 / oversampling`` detector pixels and is evaluated between grid points by cubic spline interpolation. The interpolation is accurate when there are at least about four grid points per FWHM of the ePSF, so the oversampling factor should be at least ``4 / FWHM`` with the FWHM in pixels (measured along the narrowest direction of an elongated PSF). For example, the smallest adequate oversampling factor is 3 for a FWHM of about 1.5 pixels, 2 for a FWHM of about 2 pixels, and 1 for a FWHM of 4 pixels or more. :class:`~photutils.psf.EPSFBuilder` estimates each grid point from the star pixels within 0.375 pixel of it along each axis, or within one grid spacing if that is larger. This box does not shrink as the oversampling factor grows. A larger factor therefore neither makes the ePSF noisier nor requires more stars. The grid points are not independent, however, because neighboring points share most of their star pixels, so a finer grid reduces the interpolation error but does not resolve finer structure. This behavior was confirmed with seven simulated ePSFs (Gaussian, Moffat, JWST, and Roman models with FWHMs of 1.3 to 4.1 pixels). Each was built from 40, 150, and 450 stars with oversampling factors of 1 to 8. Too small a factor clearly degraded the result. For the ePSFs with a FWHM of 1.3 to 1.5 pixels, the errors of the fitted star positions were about twice as large with a factor of 2 as with a factor of 4, and four to six times as large with a factor of 1. Increasing the factor beyond 4 did not help, because factors of 6 and 8 gave about the same accuracy as 4. It also did no harm, even with only 40 stars, apart from a run time that grew by a factor of 2 to 3 for each doubling of the oversampling factor. The tests used clean simulated stars with random subpixel phases. The default factor of 4 is therefore a good choice for a FWHM of about 1 pixel or more, and a larger factor is needed only for a smaller FWHM. A smaller factor that still satisfies ``oversampling >= 4 / FWHM`` saves run time and memory, and for well-sampled data (a FWHM of 4 pixels or more) it gives the same fitted positions and fluxes. Choosing the ePSF Size ^^^^^^^^^^^^^^^^^^^^^^ The size of the ePSF is set by the size of the star cutouts, or by the ``shape`` keyword of :class:`~photutils.psf.EPSFBuilder`. An image-based PSF model is zero outside of its image, so the flux of the PSF wings beyond the ePSF image is not in the model. Stars subtracted with the model leave their outer wings in the residual image, and the sum of the model over the detector pixels changes with the subpixel position of the star, because the number of detector pixels inside the ePSF image changes (see :ref:`psf-image-models` for example values). The ePSF should therefore extend to where the PSF is small compared with the accuracy that is needed. A larger ePSF has a cost. The stars must be isolated over the larger cutout, and the outer part of the ePSF is built from pixels that are dominated by the background noise. The fitted positions and fluxes of the stars do not depend on the ePSF size, because only the central pixels of each star are fitted. Choosing the Star Sample ^^^^^^^^^^^^^^^^^^^^^^^^ The noise of the ePSF falls as more stars are used. In the tests described above, the residuals of the ePSF built from 450 stars were about three times smaller than those of the ePSF built from 40 stars. A few hundred stars is a good target, and the number that is needed does not depend on the oversampling factor. The subpixel phases of the star centers must still be spread over the whole pixel, because structure in the ePSF that is finer than a pixel is constrained only by stars at different phases. A set of exposures dithered by fractions of a pixel that uniformly cover the subpixel phases is far more effective than random placement and also allows the star fluxes and positions to be constrained across images (see :ref:`epsf-linked-stars`). For an undersampled detector, linking the stars of dithered exposures improves the ePSF more than any builder setting does. The stars should be bright but unsaturated, isolated (no neighbors within the cutout), free of cosmic rays and detector artifacts, and have a clean background subtraction so that the total flux of each cutout is a reliable normalization. Just as important, all of the stars must share the same PSF. Do not combine exposures with different seeing or focus, and do not mix regions of the field where the PSF differs unless the variation is small compared to the accuracy you need. Heterogeneous stars produce pixel-to-pixel noise in the oversampled grid that biases the fitted star centers toward particular subpixel phases, and the builder emits a warning if the subpixel phases of the fitted centers are strongly nonuniform at the end of the build. In that case, inspect the star sample rather than increasing the number of iterations. Finally, check the result. The subpixel phases of the fitted star centers should be uniformly distributed, and the fitted fluxes and positions of the stars (or of an independent set of stars) should not depend on their subpixel phase. .. _epsf-anderson-differences: Differences from the Anderson and King Algorithm ------------------------------------------------ :class:`~photutils.psf.EPSFBuilder` implements the ePSF concept and the iterative building procedure of `Anderson and King 2000 (PASP 112, 1360) `_ (hereafter AK2000) and `Anderson 2016 (WFC3 ISR 2016-12) `_ (hereafter ISR 2016-12). The implementation differs from Anderson's approach in several steps. This section describes these differences to facilitate comparisons between ePSFs built with Photutils and those built with Anderson's method, such as the library ePSFs distributed for HST and JWST. The basic procedure is the same. For each star, after subtracting the background and normalizing by the star's flux, the value of each pixel provides a sample of the ePSF at the pixel's offset from the star center. The ePSF is tabulated on a grid that is finer than the detector pixels. In each iteration the differences between the samples and the current ePSF are combined with a robust average at each grid point and added to the ePSF, which is then smoothed and recentered. The stars are then fit again with the improved ePSF. A star is also placed on an image in the same way in both. The ePSF is evaluated once per pixel, at the offset of the pixel center from the star center, and multiplied by the flux. The ePSF already includes the integration over the pixel, so no further integration is performed. The Anderson and King method was developed for HST images. Its constants are given in detector pixels or in grid points of an ePSF with an oversampling factor of 4, and they suit a PSF with a FWHM of about 1.5 to 2 pixels. :class:`~photutils.psf.EPSFBuilder` is meant to work for any instrument, sampling, and oversampling factor, and for a single exposure. Most of the differences follow from that. The others make the build robust for star samples that are heterogeneous, contaminated, or of low signal-to-noise. The following table compares the steps. The entries for Anderson come from the two publications. .. list-table:: :header-rows: 1 :widths: 16 42 42 * - Step - Anderson - ``EPSFBuilder`` * - ePSF grid - Oversampling factor of 4. The grid covers 5x5 pixels (21x21 points) in AK2000 and 25x25 pixels (101x101 points) in ISR 2016-12. - Any integer oversampling factor, which can differ along the two axes. The grid covers the star cutouts unless ``shape`` is given. * - Variation over the detector - A 3x3 array of ePSFs across each detector, interpolated bilinearly to the position of each star. - A single ePSF. Build one ePSF per detector region to make a `~photutils.psf.GriddedPSFModel`. * - Background - Measured for each star in an annulus around it. AK2000 uses the mode of the pixels 4 to 7 pixels from the star. - Not measured. The star cutouts must be background subtracted by the user. * - Residual sampling - Each grid point uses the samples within 0.25 pixel of it along each axis. - Each grid point uses the samples within 0.375 pixel of it along each axis, and within at least one grid spacing. * - Combining the residuals - Mean with iterative rejection of the samples more than 2.5 sigma from it. - Median after sigma clipping at 3 sigma (``sigma_clip``). * - Smoothing of the core - A 5x5 least-squares quartic kernel in grid points, in every iteration. - A least-squares quartic kernel whose width is 0.7 FWHM, in every iteration. No smoothing if that is less than 5 grid points (``smoothing_kernel``). * - Smoothing of the wings - Stronger smoothing at fixed radii in every iteration. ISR 2016-12 allows quadratic variations beyond 3 pixels and uses a 3x3 boxcar beyond 5 pixels. - Quadratic fits in boxes of 1.25 and 1.75 FWHM beyond 3.5 and 5 FWHM, applied once to the final ePSF (``wing_smoothing``). * - Fourier filter - None. - A low-pass filter that removes the frequencies at and above one cycle per pixel, in every iteration, for oversampling factors greater than 1 (``alias_passband``). * - Centering - AK2000 requires equal values half a pixel on either side of the center. ISR 2016-12 shifts the ePSF to the position where it is most symmetric about its center within a radius of 1.5 pixels. - The ISR 2016-12 definition (`~photutils.centroids.centroid_symmetry`), measured in a 5x5 pixel box. The offsets are spaced by half a grid point for oversampling factors less than 4 (``recentering_func`` and ``recentering_boxsize``). * - Normalization - The pixel values of a star of unit flux sum to 1 over its central 5x5 pixels (AK2000) or within a radius of 5.5 pixels (ISR 2016-12). - The ePSF sums to the product of the oversampling factors over the whole grid, so the pixel values of a star of unit flux sum to 1 over an area the size of its cutout. * - Iteration scheme - An inner loop of 5 ePSF updates (adjust, smooth, and recenter) with the stars held fixed, inside an outer loop that fits the stars again. The outer loop continues until the fitted positions and fluxes show no trend with pixel phase (12 iterations in AK2000, 9 in ISR 2016-12). - One ePSF update per fit of the stars until the star centers converge (``center_accuracy``, ``converged_fraction``, and ``maxiters``). Then refinement iterations of 5 ePSF updates per fit of the stars, with a wider filter for oversampling factors of 4 or more (``refinement_iters``). * - Fitting the stars - Weighted by the expected Poisson noise. AK2000 fits the pixels within 1.5 pixels of the center, with a taper to 2 pixels, and solves for the position with Newton-Raphson steps. ISR 2016-12 fits the central 5x5 pixels with a grid search for the position. - A box 2 FWHM wide and at least 5 pixels (``fit_shape``), weighted only if the input data have uncertainties, with a nonlinear least-squares fitter from Astropy (``fitter``). * - Dithered exposures - Central to the method. The positions of each star are transformed to a common frame, and the positions and fluxes are averaged over the exposures after every fit. - Optional. The centers and fluxes of linked stars are constrained across the images using their WCS (see :ref:`epsf-linked-stars`). * - Interpolation of the ePSF - A bicubic spline within 4 pixels of the center and bilinear interpolation farther out (ISR 2016-12). - A single bicubic spline over the whole grid. The differences that change the ePSF the most are explained below. **Residual sampling and the Fourier filter.** The pixels of a star sample the ePSF on a lattice with a spacing of one pixel. With a narrow sampling box, each star contributes to only some of the grid points, and noise with a period of one pixel in the ePSF and biases in the fitted star centers can reinforce each other from one iteration to the next. For heterogeneous or contaminated stars this grows into a checkerboard pattern. The wider box and the low-pass filter of :class:`~photutils.psf.EPSFBuilder` prevent it. In tests with heterogeneous stars, a box of 0.25 pixel regrew the pattern. The cost is that the filter and the wider box remove some real signal of an undersampled ePSF just below one cycle per pixel, which the refinement iterations restore (see :ref:`epsf-alias-passband`). **Smoothing.** A 5x5 quartic kernel is about 0.7 FWHM wide for HST data with an oversampling factor of 4. Applied to an ePSF with fewer grid points per FWHM it lowers the peak, and applied to one with many more it removes little noise. The same holds for wing smoothing at fixed radii in pixels. :class:`~photutils.psf.EPSFBuilder` therefore scales both with the measured FWHM. It smooths the wings only once, after the last iteration, because smoothing them in every iteration changed the core of the ePSF through the normalization. **Iteration scheme.** Anderson's inner loop lets the ePSF settle for fixed star positions before the stars are fit again, and the average over the dithered exposures breaks the degeneracy between the shape of the ePSF and the star positions. :class:`~photutils.psf.EPSFBuilder` must also work for a single exposure, where each star has only one fit. It fits the stars after every ePSF update, and it uses Anderson's scheme of several updates per fit only in the refinement iterations. Several updates per fit from the start of the build did not improve the ePSF in tests. **Centering.** The definition of the center of an ePSF is arbitrary as long as the same ePSF is used to build and to fit. The centering definitions agree for a symmetric ePSF. For an asymmetric ePSF they differ by a small constant offset. The offset has no effect on photometry or on relative astrometry made with the same ePSF, because the fitted star positions shift with it. Positions measured with ePSFs that were centered differently differ by that offset. :class:`~photutils.psf.EPSFBuilder` uses the symmetry definition of ISR 2016-12 (`~photutils.centroids.centroid_symmetry`) by default. The center of mass (`~photutils.centroids.centroid_com`), which was the default before version 3.1, is more sensitive to asymmetric structure around the core. In tests with simulated JWST and Roman PSFs it was offset from the position of the source in the optical model by up to 0.1 pixel, compared with 0.05 pixel or less for the symmetry center. The ``center_asymmetry`` attribute of the results gives the difference between the two centers for any built ePSF. The recentering function and box can be changed with ``recentering_func`` and ``recentering_boxsize``. **Normalization and background.** These two conventions set the flux scale and must be kept in mind when ePSFs from the two sources are mixed: * Fluxes fit with an ePSF from :class:`~photutils.psf.EPSFBuilder` are the fluxes within the area of the ePSF grid. Fluxes fit with one of Anderson's library ePSFs are the fluxes within a radius of 5.5 pixels (for the ISR 2016-12 models). The values of such a library ePSF sum to more than the product of the oversampling factors over the whole grid (5 to 8 percent more for the HST WFC3/IR F110W and F160W models). `~photutils.psf.ImagePSF` and `~photutils.psf.GriddedPSFModel` do not renormalize their input, so they return fluxes in the convention of the ePSF that they are given. * An annulus close to a star contains light from the wings of the star. An ePSF built with such a local background has a small constant subtracted from it compared with one built with the background far from the stars (0.05 to 0.5 percent of the peak in tests). Both are valid. Use the same background convention when building an ePSF and when fitting stars with it. **Interpolation.** Interpolation schemes differ most where the ePSF curves strongly between grid points. In the cores of undersampled ePSFs with an oversampling factor of 4 they can differ by several tenths of a percent of the peak. An ePSF is built to reproduce the star pixels with the interpolation that was used to build it, so a library ePSF that is evaluated with a different interpolation can show residuals of that size in the cores of bright stars. **Alternatives proposed by Godden and Blundell.** `Godden and Blundell 2026 (RASTI 5, 1) `_ tested the steps of the method with simulated stars and proposed two changes that apply to both Anderson's method and :class:`~photutils.psf.EPSFBuilder`. Both were tested with the current builder and neither is implemented: * A clipped mean or median of the samples around a grid point underestimates the ePSF where it has a local maximum, i.e., at its peak. They found much smaller errors by fitting a two-dimensional polynomial surface to the samples and evaluating it at the grid point. Their tests used known star positions and took the samples of a single pass from the grid cell nearest each grid point. That result was reproduced under the same conditions. It does not carry over to :class:`~photutils.psf.EPSFBuilder`, which combines the residuals of the samples from the current ePSF evaluated at the position of each sample. The curvature of the ePSF within the sampling box is then removed by the model, and the bias of the median vanishes as the iteration converges. In tests, the median and a quadratic surface fit converged to nearly the same ePSF. The surface fit was less accurate for undersampled ePSFs (FWHM below about 1.5 pixels) when the build stopped at its convergence criterion, and noisier for well-sampled ones. A least-squares fit is also not robust. For a sample that included cutouts without a star and stars with different shapes, the surface fit grew a checkerboard pattern in most of the builds, while the median did not in any of them. * Interpolating the ePSF grid with a radial basis function with a cubic kernel was more accurate than a bicubic spline. In tests with analytic pixel-integrated profiles, its interpolation errors were 2 to 4 times smaller for oversampling factors of 3 or larger and not better for factors of 1 and 2. For an oversampling factor of 4 the interpolation error of the bicubic spline is already small. For an ePSF with a FWHM of 1 pixel it was at most 0.08 percent of the peak in the core, and the typical (RMS) error was about ten times smaller, 0.007 percent. The interpolation is therefore not what limits the accuracy of an ePSF built from stars. In tests with a few hundred stars, the difference between the built ePSF and the true one was about ten times larger than the interpolation error (a maximum of 0.2 to 1 percent of the peak and an RMS of about 0.1 percent), so a more accurate interpolation would not noticeably improve the result. The radial basis function is also about two hundred times slower to evaluate. If the interpolation error of the bicubic spline is too large for an application, building the ePSF with a larger oversampling factor also reduces it, without slowing the evaluation of the ePSF. astropy-photutils-24be529/docs/user_guide/geometry.rst000066400000000000000000000007161526452531300232640ustar00rootroot00000000000000Geometry 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-24be529/docs/user_guide/grouping.rst000066400000000000000000000267701526452531300232730ustar00rootroot00000000000000.. _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. Sources separated by less than or equal to this distance are placed in the same group. 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 finds the groups as the connected components of the graph linking pairs of sources separated by less than or equal to the minimum separation distance. The result is identical to single-linkage hierarchical agglomerative clustering with a distance criterion, but it is computed using a KD-tree so that it scales to large numbers of sources. 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` function 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_ids``, where each integer represents the group to which the corresponding star belongs. 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 and 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}') 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-24be529/docs/user_guide/index.rst000066400000000000000000000027361526452531300225440ustar00rootroot00000000000000.. _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 See also :doc:`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 Serialization ------------- .. toctree:: :maxdepth: 1 asdf.rst Utilities --------- .. toctree:: :maxdepth: 1 utils.rst geometry.rst astropy-photutils-24be529/docs/user_guide/isophote.rst000066400000000000000000000246321526452531300232660ustar00rootroot00000000000000Elliptical 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.00000000e+00 2.80648458e-02 2.50727634e-02 2.14181951e-03 3.10787728e+00 3.03855164e+00 2.91189209e+00 2.55716884e+00 2.55716884e+00 2.38315212e-02 2.38315212e-02 1.65061125e-02 5.42895191e-03 9.29065433e-02 1.08616258e-01 1.70659368e-01 3.49426737e-01 3.80370926e-01 4.56646465e-01 7.06300852e-01 1.15223391e+00 9.98739308e-01 9.93279385e-01 6.13496256e-01 6.13496256e-01 6.52734483e-01 7.40866510e-01 7.64426024e-01 6.98377544e-01 6.77763424e-01 6.81948730e-01 6.28697041e-01 6.63983464e-01 6.91218304e-01 6.96058515e-01 6.93662495e-01 6.86066879e-01 6.78830046e-01 6.90474872e-01 7.06300852e-01 6.87365631e-01 6.78462108e-01 6.91947812e-01 6.88590347e-01 6.89747001e-01 7.04595574e-01 6.98160861e-01 7.01980601e-01 6.86722136e-01 6.86722136e-01 6.86722136e-01 7.14498131e-01 7.14498131e-01 7.14498131e-01] 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 ... n_iter stop_code ... ------------------ ------------------- ... ------ --------- 0.0 103.36486934428946 ... 0 0 0.5346972612827552 101.85757682152568 ... 10 0 0.5881669874110307 101.6870361556852 ... 10 0 0.6469836861521338 101.5050654325796 ... 10 0 0.7116820547673471 101.37516112942762 ... 10 0 ... ... ... ... ... 47.15895382000003 6.092927277963179 ... 10 0 51.874849202000036 3.408798159078182 ... 10 0 57.06233412220004 1.5402962026632605 ... 50 2 62.76856753442005 0.7694404504162479 ... 50 2 69.04542428786206 0.17437889366851772 ... 2 5 75.94996671664828 0.16924150035440783 ... 3 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(nrows=2, ncols=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(ncols=3, figsize=(14, 5)) 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: multiband photometry and masked arrays `_ API Reference ------------- :doc:`../reference/isophote_api` .. toctree:: :hidden: isophote_faq.rst astropy-photutils-24be529/docs/user_guide/isophote_faq.rst000066400000000000000000000225111526452531300241070ustar00rootroot00000000000000.. _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 of 3 of 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 of 0.8. This factor is roughly what is expected from semimajor-axis geometrical-sampling steps of 10 - 20% and a de Vaucouleurs law or an exponential disk in its inner region (r <~ 5 req). When using the last isophote's gradient as an 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 uses the current gradient plus its error as the reference value. 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 toward 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 fixed relative gradient error of 0.8 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 6 or fewer. 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 to 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 an 11x11 pixel window centered on the input object coordinates. At each scan position, it extracts two concentric, roughly circular samples, an inner disk with a radius of about 3.5 pixels and an outer ring with a radius of about 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-24be529/docs/user_guide/morphology.rst000066400000000000000000000145371526452531300236360ustar00rootroot00000000000000Morphological 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 estimate the background as the median of a blank region of the image. First, we create the image, subtract its background, and extract a cutout of the source:: >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) # subtract background >>> data = data[40:80, 75:105] # extract single object 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.QTable` 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 14.931699 19.904702 6.112748 3.475815 59.965829 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 data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) # subtract background data = data[40:80, 75:105] # extract single object mask = data < 50 cat = data_properties(data, mask=mask) 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_value(u.radian) apertures = EllipticalAperture(xypos, a, b, theta=theta) fig, ax = plt.subplots(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 have any dimensionality (e.g., a 1D array or a 2D image), with its values treated as a flattened set. 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.23039153243750068 API Reference ------------- :doc:`../reference/morphology_api` astropy-photutils-24be529/docs/user_guide/psf.rst000066400000000000000000001374451526452531300222330ustar00rootroot00000000000000.. _psf-photometry: PSF Photometry (`photutils.psf`) ================================ The `photutils.psf` subpackage contains tools for model-fitting photometry, often called "PSF photometry". 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 for the grouping algorithm proposed by Stetson. Sources that are 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. All of the PSF models provided by `photutils.psf`, both the analytic and the image-based models, provide analytic Jacobians that the fitters use automatically, avoiding finite-difference approximations of the parameter derivatives and speeding up the fits. 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-terminology: Terminology ----------- PSF photometry measures the flux and position of a star by fitting a model of how the light of a point source is distributed over the pixels of an image. Several closely related terms are used for such models, including "PSF", "PRF", and "ePSF", and different astronomy subfields use them in slightly different ways. This section defines the terms as they are used in `photutils.psf`. Point Spread Function (PSF) The PSF is the distribution of light from a point source at the detector, after the light has passed through the atmosphere (for ground-based data) and the telescope optics. It is a smooth, continuous function of position that does not depend on the detector pixels. It is sometimes called the instrumental PSF (iPSF) to make that explicit. Pixel response function A detector does not record the PSF itself. Each pixel collects the light that falls on its area and reports a single value. The pixel response function describes how sensitive a pixel is to light at each position within it. The simplest and most common assumption is that a pixel is equally sensitive over its whole area. Some authors abbreviate the pixel response function as "PRF". We always write it out in full, because we use "PRF" for the Point Response Function defined next. Effective PSF (ePSF) or Point Response Function (PRF) The ePSF is the PSF as it is recorded by the pixels. Formally, it is the convolution of the PSF and the pixel response function. Its value at a given offset from a source is the fraction of the source flux that is recorded by a pixel centered at that offset. For pixels with a uniform response, that is the integral of the PSF over the area of the pixel. "ePSF" and "PRF" are two names for the same function. The name "ePSF" comes from `Anderson & King 2000 `_, which describes this formalism in detail. The name "PRF" emphasizes that the function describes the response of the detector to a point source, rather than the PSF of the optics. The pixel values of an image are samples of the ePSF, not of the PSF. Like the PSF, the ePSF is a continuous function, because a pixel could be centered at any offset from a source. The image of a star samples the ePSF at a spacing of one pixel, at offsets that depend on where the center of the star falls within a pixel. An oversampled ePSF samples the same function on a finer grid. Each of its values is still the flux in a whole detector pixel, not the flux in a subpixel. A model that is fit to the pixel values of an image should therefore represent the ePSF. The names of the models in `photutils.psf` show what each model represents: - The analytic models whose names end in ``PSF`` (e.g., `~photutils.psf.CircularGaussianPSF`) give the value of the PSF at the input positions. They are not integrated over the pixels. - The analytic models whose names end in ``PRF`` (e.g., `~photutils.psf.CircularGaussianPRF`) are integrated over the pixels, assuming that the response is uniform across a pixel. - The image-based models (`~photutils.psf.ImagePSF` and `~photutils.psf.GriddedPSFModel`) interpolate an input image and do not integrate it over the pixels. Despite the "PSF" in their names, their input images must be ePSFs. An ePSF that is built from observed stars (see :ref:`build-epsf`) includes the actual response of the pixels. The difference between the PSF and the ePSF is largest for undersampled data, where the PSF is narrow compared with a pixel, and for detectors with significant sensitivity variations within a pixel. For such data, the PSF is much sharper than the image of a star, and the sum of its values at the pixel centers changes with the subpixel position of the star. The difference is smaller for well-sampled data, but it does not vanish, because the integration over a pixel always broadens the profile. A PSF model with a known, fixed width is then narrower than the image of a star, which biases the fitted flux. The distinction is largely inconsequential when the model width is measured directly from the pixelated image of a well-sampled star, as the measured width already incorporates the effects of broadening. In common usage, all of these functions are often simply called the "PSF", and "PSF photometry" refers to the general technique of model-fitting photometry, regardless of exactly which kind of model is fit. We use both terms in that broad sense throughout `photutils.psf` and this documentation, and we use the specific terms defined above where the distinction matters. .. _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, STPSF 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. The value of the model at a position should be the flux in a detector pixel centered at that position (see :ref:`psf-terminology`), especially for data that are undersampled. 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 PRF 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.MoffatPRF`: a 2D Moffat PRF model with the same parameters as `~photutils.psf.MoffatPSF`. - `~photutils.psf.AiryDiskPSF`: a 2D Airy disk PSF model parameterized in terms of the position, total flux, and radius of the first dark ring. - `~photutils.psf.AiryDiskPRF`: a 2D Airy disk PRF model with the same parameters as `~photutils.psf.AiryDiskPSF`. 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 values of a PSF model on a grid of detector pixels are the values of the PSF at the pixel centers, not the fluxes in the pixels. For a PSF that is undersampled by the detector pixels (a FWHM of less than about 2 pixels), a PSF model is sharper than the sources in the data and the sum of its values over the pixels depends on the subpixel position of the source. Even for a well-sampled PSF, a PSF model whose shape parameters are fixed gives fitted fluxes that are biased by a few percent for a FWHM of 2 to 3 pixels, because the integration over a pixel broadens the image of a source. The PRF models should therefore be used to fit the pixel values of an image, e.g., for PSF photometry. Every PSF model above has a PRF counterpart. The Gaussian PRF models are integrated over the pixels analytically. The Moffat and Airy disk PRF models are integrated numerically at ``n_nodes**2`` points in each pixel (81 by default), so they are much slower to evaluate than their PSF counterparts. On a 7 x 7 pixel cutout, which is typical for PSF fitting, `~photutils.psf.MoffatPRF` takes about 0.05 ms to evaluate and `~photutils.psf.AiryDiskPRF` about 0.08 ms, which is about 10 times slower than `~photutils.psf.MoffatPSF` (0.005 ms) and `~photutils.psf.AiryDiskPSF` (0.009 ms). On a 101 x 101 pixel image they take about 7 ms and 13 ms, which is about 70 times slower than the PSF models (0.1 ms and 0.2 ms). A smaller ``n_nodes`` reduces the time at the cost of accuracy. If one needs a custom PRF model based on an analytical PSF model that has no PRF counterpart, evaluate the PSF model on an oversampled grid (a Moffat profile is used below only as an illustration) and integrate the result over the detector pixels with `~photutils.psf.make_epsf_from_psf`. The resulting image can then be used as the input to `~photutils.psf.ImagePSF` (see :ref:`psf-image-models` below) with the same oversampling factor to create an image-based PSF model:: >>> import numpy as np >>> from photutils.psf import ImagePSF, MoffatPSF, make_epsf_from_psf >>> oversampling = 4 >>> yy, xx = np.mgrid[-50:51, -50:51] / oversampling >>> psf = MoffatPSF(alpha=1.2, beta=2.5)(xx, yy) >>> epsf = make_epsf_from_psf(psf, oversampling=oversampling) >>> model = ImagePSF(epsf, oversampling=oversampling) The values of the oversampled PSF model sum to the square of the oversampling factor, apart from the flux outside of the grid, which is the normalization that `~photutils.psf.ImagePSF` requires. Discretizing the model on the detector pixel grid with :func:`~astropy.convolution.discretize_model` (with ``mode='oversample'`` or ``mode='integrate'``) also gives a pixel-integrated image, but only for one subpixel position of the source. A model made from that image is not accurate at other positions unless the PSF is well sampled. Note that all of the analytic models above have additional shape parameters beyond the standard PSF model parameters of position and flux (``x_0``, ``y_0``, and ``flux``), which are fixed by default as described above. .. _psf-custom-models: Custom PSF Models ^^^^^^^^^^^^^^^^^ It is recommended that one use the PSF models provided by `photutils.psf` because they are optimized for PSF photometry. If a different PSF profile is needed, there are two ways to make a model for it. If the shape of the PSF is known and does not need to be fit, evaluate the profile on an oversampled grid and use the result to make an image-based model with :func:`~photutils.psf.make_epsf_from_psf` and `~photutils.psf.ImagePSF`, as shown above for the Moffat profile. This is the recommended approach because the resulting model is integrated over the detector pixels. If the shape parameters of the PSF need to be fit, define a model with the Astropy modeling framework. The PSF photometry classes accept any `~astropy.modeling.Fittable2DModel` that has parameters named ``x_0``, ``y_0``, and ``flux``. The model must be normalized so that ``flux`` is the total flux of the source for any values of the shape parameters. For example, a PSF model that is the sum of two circular Gaussians with the same center:: >>> import numpy as np >>> from astropy.modeling import Fittable2DModel, Parameter >>> class DoubleGaussianPSF(Fittable2DModel): ... flux = Parameter(default=1.0) ... x_0 = Parameter(default=0.0) ... y_0 = Parameter(default=0.0) ... sigma_core = Parameter(default=1.0, fixed=True) ... sigma_wing = Parameter(default=3.0, fixed=True) ... wing_ratio = Parameter(default=0.1, fixed=True) ... ... @staticmethod ... def evaluate(x, y, flux, x_0, y_0, sigma_core, sigma_wing, ... wing_ratio): ... r2 = (x - x_0)**2 + (y - y_0)**2 ... core = np.exp(-0.5 * r2 / sigma_core**2) ... wing = wing_ratio * np.exp(-0.5 * r2 / sigma_wing**2) ... norm = 2.0 * np.pi * (sigma_core**2 ... + wing_ratio * sigma_wing**2) ... return flux * (core + wing) / norm >>> model = DoubleGaussianPSF(sigma_core=1.2, sigma_wing=3.5) This model is evaluated at the input positions and is not integrated over the detector pixels, so it has the same limitations as the ``*PSF`` models described above. It is sharper than the sources in the data, and its fitted fluxes are biased unless the PSF is well sampled by the detector pixels. For accurate photometry, the ``evaluate`` method should instead return the profile integrated over each pixel, as the ``*PRF`` models provided by `photutils.psf` do. A model whose position and flux parameters have other names can also be used if it has ``x_name``, ``y_name``, and ``flux_name`` attributes that contain the names of those parameters. The :func:`~photutils.psf.make_psf_model` function, which wraps an Astropy fittable 2D model in a compound model, is deprecated and will be removed in version 4.0. Use one of the two approaches above instead. .. _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. These models interpolate the input image(s) and do not integrate them over the detector pixels. The input must therefore be an effective PSF (ePSF), in which each value is the fraction of the source flux that falls in a whole detector pixel centered at that position relative to the source. An oversampled PSF whose values are samples of the PSF, such as the output of an optical model, is not an ePSF. A model made from such an image is sharper than the sources in the data. If the PSF is also undersampled by the detector pixels, the sum of the model over the detector pixels changes with the subpixel position of the source. Use `~photutils.psf.make_epsf_from_psf` to make an ePSF from a sampled PSF:: >>> from photutils.psf import ImagePSF, make_epsf_from_psf >>> epsf = make_epsf_from_psf(psf, oversampling=4) # doctest: +SKIP >>> model = ImagePSF(epsf, oversampling=4) # doctest: +SKIP An optical model with an even oversampling factor typically returns an image with an even number of points along each axis, with the PSF centered between the four central grid points. The image-based models accept such an image, and the ePSF made from it, as they are. If an ePSF with the PSF center on a grid point is needed, for example to compare it with an ePSF made by `~photutils.psf.EPSFBuilder`, use the ``midpoints=True`` option of `~photutils.psf.make_epsf_from_psf`. It makes the ePSF at the points midway between the input grid points, so the output image has one fewer point along each axis. The gridded ePSF models that `STPSF `_ makes with its ``psf_grid`` method (see `~photutils.psf.webbpsf_reader`) are already integrated over the detector pixels, so `~photutils.psf.make_epsf_from_psf` must not be applied to them. STPSF integrates the sampled PSF with a discrete box kernel, however, which is a low-order approximation of the integral. In tests with STPSF 2.2.0 and an oversampling factor of 4, the ePSF peak was too low by 2.5%, 1.1%, and 0.9% for the JWST NIRCam F115W, F200W, and F444W filters, and the fluxes fitted with these models to stars made from an accurate ePSF were too high by 1.3%, 0.6%, and 0.5%. With an oversampling factor of 5 the errors were about a third as large and had the opposite sign. When that accuracy matters, make the ePSF from the oversampled PSF that the STPSF ``calc_psf`` method returns, which is sampled at the grid points:: import stpsf from photutils.psf import ImagePSF, make_epsf_from_psf nrc = stpsf.NIRCam() nrc.filter = 'F115W' oversampling = 4 hdulist = nrc.calc_psf(fov_pixels=101, oversample=oversampling) psf = hdulist['OVERDIST'].data * oversampling**2 epsf = make_epsf_from_psf(psf, oversampling=oversampling) model = ImagePSF(epsf, oversampling=oversampling) The values that ``calc_psf`` returns sum to the fraction of the flux that is inside the field of view, so multiplying them by the square of the oversampling factor gives the normalization that `~photutils.psf.ImagePSF` requires. An image-based model is zero outside of its input image, so the flux of the PSF wings beyond the image is not in the model. The sum of the model over the detector pixels is then smaller than the model flux, and it changes with the subpixel position of the source because the number of detector pixels inside the image changes. For a simulated JWST NIRCam F115W ePSF, the sum changes by up to 2.5% for an image that covers 6x6 detector pixels and by up to 0.6% for one that covers 16x16 pixels. The ePSF image should therefore be large enough that the ePSF is small at its edges. The fluxes and positions fitted by the PSF photometry classes are not affected if the fitted region of each source is well inside the image, but the model and residual images do not include the flux outside of it. .. _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 `photutils.psf` and :ref:`photutils.datasets ` modules:: >>> 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.full(data.shape, 1.0) 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 of 4 pixels. The central 5x5 pixel region of each source will be fit using a `~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 nonfinite (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.QTable` 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')]) id x_fit y_fit flux_fit --- ------- ------- -------- 1 54.5716 7.7458 513.2209 2 29.0873 25.6223 534.1826 3 79.6336 28.7482 619.7632 4 63.2403 48.6157 560.0202 5 88.8816 54.1347 616.2282 6 79.8673 61.1302 650.1411 7 90.9502 72.0862 597.1119 8 7.8034 78.5703 638.4794 9 5.5219 89.8622 542.6817 10 71.8331 90.5734 692.6518 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.full(data.shape, 1.0) 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(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' >>> psfphot.finder_results['sharpness'].info.format = '.4f' >>> 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) id x_centroid y_centroid sharpness ... peak flux mag daofind_mag --- ---------- ---------- --------- ... ------- -------- ------- ----------- 1 54.5299 7.7460 0.6006 ... 53.5953 374.4040 -6.4334 -2.1093 2 29.0927 25.5992 0.5955 ... 57.1982 397.4367 -6.4982 -2.1958 3 79.6185 28.7515 0.5957 ... 65.7175 458.8443 -6.6542 -2.3401 4 63.2485 48.6134 0.5802 ... 58.3985 414.0679 -6.5427 -2.2209 5 88.8820 54.1311 0.5948 ... 69.1869 467.3428 -6.6741 -2.4379 6 79.8727 61.1208 0.6216 ... 74.0935 493.8497 -6.7340 -2.4799 7 90.9621 72.0803 0.6167 ... 68.4157 455.3896 -6.6460 -2.4035 8 7.7962 78.5465 0.5979 ... 66.2173 472.3818 -6.6857 -2.3167 9 5.5858 89.8664 0.5741 ... 54.3786 396.0783 -6.4945 -2.1188 10 71.8303 90.5624 0.6038 ... 73.5747 512.3211 -6.7739 -2.4516 Fitting a Single Source ^^^^^^^^^^^^^^^^^^^^^^^ In some cases, one may want to fit only a single source (or a 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')]) id x_fit y_fit flux_fit --- ------- ------- -------- 1 63.2403 48.6157 560.0201 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.full(data.shape, 1.0) 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) xypos = zip(phot['x_fit'], phot['y_fit'], strict=True) aper = CircularAperture(xypos, r=4) fig, ax = plt.subplots(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')]) id x_init y_init flux_init x_fit y_fit flux_fit --- ------ ------ --------- ----- ----- -------- 1 63 49 556.5067 63.0 49.0 539.4673 .. _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 Models 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 float or a 2-tuple of floats in ``(x, y)`` order 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 nonnegative 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 {'flux': (0.0, None), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (1.1754943508222875e-38, 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 {'flux': (0.0, None), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (1.1754943508222875e-38, 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 {'flux': (400.0, 600.0), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (1.1754943508222875e-38, 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, because the number of fitted parameters grows with the group size. 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')]) 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')]) id iter_detected x_fit y_fit flux_fit --- ------------- ------- ------- -------- 1 1 54.5694 7.7454 513.4505 2 1 29.0875 25.6216 531.3096 3 1 79.6327 28.7475 615.1111 4 2 63.2401 48.6159 560.9633 5 2 88.8813 54.1350 612.0937 6 2 79.8674 61.1301 651.4973 7 2 90.9502 72.0861 598.9888 8 2 7.8037 78.5711 642.0335 9 2 5.5218 89.8621 542.5141 10 2 71.8326 90.5721 686.2071 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 noncircular or non-Gaussian, you can fit them with the `~photutils.psf.PSFPhotometry` class and a different PSF model. Because the model is integrated over the pixels, the returned FWHM is the FWHM of the Gaussian before that integration, which is smaller than the FWHM of the pixelated image of a source. The integration over a pixel adds 1/12 pixel**2 to the variance of the profile, so the FWHM of the pixelated image is approximately ``sqrt(fwhm**2 + 8 * ln(2) / 12)``, or ``sqrt(fwhm**2 + 0.462)``, in pixels. 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'], strict=True)) >>> fwhm = fit_fwhm(data, xypos=xypos, error=error, fit_shape=(5, 5), ... fwhm=2) >>> print(fwhm) [2.70583788 2.71009672 2.67319671 2.69326964 2.66743315 2.69499684 2.68722492 2.73280668 2.72006396 2.68340929] The FWHM of the pixelated image of each source is then:: >>> fwhm_pixelated = np.sqrt(fwhm**2 + 8 * np.log(2) / 12) >>> print(fwhm_pixelated) [2.78992056 2.79405124 2.7582746 2.7777328 2.75268918 2.7794075 2.77187227 2.81608425 2.80372005 2.76817332] 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-24be529/docs/user_guide/psf_matching.rst000066400000000000000000000773521526452531300241050ustar00rootroot00000000000000.. _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`_ and `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 pixel scales, use the :func:`~photutils.psf_matching.resize_psf` function to resample one PSF to the pixel scale of the other. This function uses spline interpolation and preserves the total flux. The two PSFs may then still need to be cropped or padded to a common shape. * **Odd dimensions**: PSF arrays must have odd dimensions in both axes to ensure a well-defined center point. * **Nonzero sum**: The PSF arrays are normalized internally so that the sum of all pixels equals 1, so they must have a nonzero sum. * **Centered** (recommended but not required): The peak of the PSF should be at the center of the array. * **Sampled like the image**: The PSFs should represent a point source as it appears in the image that will be convolved, at the pixel scale of that image. That includes the integration of the PSF over the detector pixels (see :ref:`psf-terminology`). The last requirement matters only for PSFs that are undersampled by the pixels. The kernel is the ratio of the Fourier transforms of the two PSFs. The integration over a detector pixel multiplies both transforms by the same factor when the two PSFs have the same detector pixel size, so it cancels in the ratio. For PSFs that are well sampled (a FWHM of more than about 2.5 pixels), PSFs that are sampled at the pixel centers and PSFs that are integrated over the pixels therefore give the same kernel. For an undersampled source PSF the two differ, because a PSF that is sampled at points is aliased more than one that is integrated over the pixels. In tests with Gaussian PSFs, the kernels made from pixel-integrated PSFs gave matched images with about half the error of the kernels made from PSFs sampled at points. The error was still 2 to 4% of the peak of the matched image for a source PSF with a FWHM of 1.2 pixels, and about 0.1% for a FWHM of 1.65 pixels. An undersampled image cannot be matched exactly by a kernel at its own pixel scale, whichever PSFs are used. If the two PSFs come from images with different detector pixel sizes, the pixel integration does not cancel, and each PSF should include the pixel integration of its own image. :func:`~photutils.psf_matching.resize_psf` interpolates a PSF and does not integrate it over the pixels. An oversampled PSF that is sampled at points, such as the output of an optical model, should first be integrated over the detector pixels with :func:`~photutils.psf.make_epsf_from_psf`. For a result with an odd number of points, every ``oversampling``-th point counted from the central point is then the PSF at the pixel scale of the image. 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()) 1.0 >>> print(kernel2.sum()) 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(ncols=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 astropy.stats import gaussian_sigma_to_fwhm from photutils.psf import CircularGaussianPSF, 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) # The kernel is a Gaussian sampled at the pixel centers gm3 = CircularGaussianPSF(flux=1, x_0=25, y_0=25, fwhm=4 * gaussian_sigma_to_fwhm) 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. `~photutils.psf_matching.SplitCosineBellWindow` is the base class of the other four and 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 array radius is the distance from the array center to the array edge. For a nonsquare array, it differs between the two axes, so the window is elliptical. The window then has the same cutoff in spatial frequency along both axes. 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 suppression of ringing in the matching kernel, 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 the matching kernel. 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, strict=True): 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 PSF images from the `photutils-datasets `_ repository using :func:`astropy.io.fits.getdata`, which downloads and caches the remote files:: >>> from astropy.io import fits >>> url = ('https://github.com/astropy/photutils-datasets/raw/main/' ... 'data/irac_ch{}_flight.fits') >>> ch1_psf = fits.getdata(url.format(1)) # doctest: +REMOTE_DATA >>> ch4_psf = fits.getdata(url.format(4)) # doctest: +REMOTE_DATA Let's display the images: .. plot:: import matplotlib.pyplot as plt from astropy.io import fits from astropy.visualization import SimpleNorm url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/irac_ch{}_flight.fits') ch1_psf = fits.getdata(url.format(1)) ch4_psf = fits.getdata(url.format(4)) 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 the pixel scales differ, one can use the :func:`~photutils.psf_matching.resize_psf` convenience function to resample a PSF image. Typically, one would interpolate the lower-resolution PSF to the same pixel scale as the higher-resolution PSF. The PSFs may then need to be cropped or padded to a common shape. 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.io import fits from astropy.visualization import SimpleNorm from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/irac_ch{}_flight.fits') ch1_psf = fits.getdata(url.format(1)) ch4_psf = fits.getdata(url.format(4)) 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(nrows=2, ncols=2, figsize=(8, 7)) for ax, kernel, title in zip(axes.ravel(), kernels, titles, strict=True): 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.io import fits from astropy.visualization import SimpleNorm from scipy.signal import fftconvolve from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/irac_ch{}_flight.fits') ch1_psf = fits.getdata(url.format(1)) ch4_psf = fits.getdata(url.format(4)) 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, strict=True): 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 astropy.io import fits from scipy.signal import fftconvolve from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/irac_ch{}_flight.fits') ch1_psf = fits.getdata(url.format(1)) ch4_psf = fits.getdata(url.format(4)) 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(nrows=2, ncols=2, figsize=(9, 7)) for ax, resid, title in zip(axes.ravel(), residuals, titles, strict=True): 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 astropy.io import fits from scipy.signal import fftconvolve from photutils.profiles import CurveOfGrowth from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel) url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/irac_ch{}_flight.fits') ch1_psf = fits.getdata(url.format(1)) ch4_psf = fits.getdata(url.format(4)) 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( nrows=2, 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, strict=True): 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, strict=True): 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-24be529/docs/user_guide/radial_profiles.rst000066400000000000000000000261101526452531300245640ustar00rootroot00000000000000.. _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) [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 bin edges (``radii``). Note that this is different from the input ``radii``, which are the radial bin edges rather than centers:: >>> print(rp.radii) [ 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) [ 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) [ 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) [0.9901437 0.64531698 0.51234751 0.43720335 0.38782635 0.35176132 0.32440232 0.3025968 0.28456077 0.26947982 0.25644723 0.24531375 0.23540592 0.2266079 0.21875692 0.21164247 0.20525699 0.19930521 0.19389598 0.18892207 0.18429087 0.1800127 0.17593986 0.17221504] 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 between the minimum and maximum radii 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(25) 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 the sum of its values 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(25) 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(25) 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 >>> rp.moffat_fit 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) 11.009084813327846 >>> print(rp.moffat_fwhm) 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(25) 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-24be529/docs/user_guide/segmentation.rst000066400000000000000000001256661526452531300241420ustar00rootroot00000000000000.. _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 at least 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 (nonsources) 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(nrows=2, 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(nrows=2, 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 multithresholding technique. Photutils provides a :func:`~photutils.segmentation.deblend_sources` function that deblends sources using a combination of multithresholding and `watershed segmentation `_. Note that in order to deblend sources, they must be separated enough that there is 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 multithresholding 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. The ``contrast_method`` keyword selects the flux to which the ``contrast`` fraction refers. With the ``'basin'`` method, it is the total flux in the source's watershed basin, i.e., everything the watershed assigns to the source, including its share of any surrounding envelope. Basins below the contrast are removed iteratively and their territory is reflooded. With the ``'saddle'`` method, it is only the flux the source holds above the saddle level where it separates from its neighbors. This is the significance criterion that `SourceExtractor`_ applies with its ``DEBLEND_MINCONT`` parameter, although the two codes space their threshold levels differently (SourceExtractor between the detection threshold and the peak, photutils between the source minimum and maximum) and assign the remaining pixels differently (SourceExtractor with a profile model, photutils with a watershed). The saddle flux measures the significance of the peak itself, independently of how much envelope territory the source would inherit, which makes it stricter for faint peaks sitting on bright envelopes. Because it excludes the flux below the saddle, the same ``contrast`` value selects sources differently between the two methods. The saddle method is evaluated once during marker construction and needs only a single watershed pass, so it is never slower than the basin method and is substantially faster when many below-contrast basins would otherwise be removed one at a time. The default of `None` currently resolves to ``'basin'``. The default may change to ``'saddle'`` in version 4.0. Here's a simple example of source deblending:: >>> from photutils.segmentation import deblend_sources >>> segment_map2 = deblend_sources(convolved_data, segment_map, ... n_pixels=10, n_levels=32, contrast=0.001) 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) fig, ax = plt.subplots(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) fig, (ax1, ax2, ax3) = plt.subplots(ncols=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:: >>> from photutils.segmentation import SourceFinder >>> finder = SourceFinder(n_pixels=10) >>> 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:: >>> 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:: >>> 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:: >>> 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) segment_map = finder(convolved_data, threshold) fig, (ax1, ax2) = plt.subplots(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]) >>> [int(segment.label) for segment in segments] [1, 5, 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:: >>> 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:: >>> 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 sky_centroid ... kron_flux kron_flux_err flags ... ----- ---------- ---------- ------------ ... --------- ------------- ------ 1 235.38 1.44 None ... 490.35 nan 10256 2 493.78 5.84 None ... 489.37 nan 10256 3 207.29 10.26 None ... 694.24 nan 0 4 364.87 11.13 None ... 681.20 nan 8192 5 257.85 12.18 None ... 748.18 nan 0 6 289.79 22.39 None ... 941.39 nan 2048 7 379.21 27.86 None ... 635.38 nan 0 8 441.42 31.26 None ... 742.80 nan 2048 9 358.54 36.08 None ... 449.14 nan 2048 ... ... ... ... ... ... ... ... 84 208.00 199.45 None ... 1008.23 nan 133152 85 220.83 198.24 None ... 546.55 nan 131104 86 426.56 211.19 None ... 876.52 nan 395296 87 419.52 216.55 None ... 814.66 nan 131104 88 302.52 238.92 None ... 505.42 nan 393248 89 292.77 244.93 None ... 792.63 nan 131104 90 32.66 241.24 None ... 930.77 nan 131104 91 42.60 249.43 None ... 580.54 nan 395296 92 433.80 280.74 None ... 663.44 nan 133152 93 434.03 288.88 None ... 879.64 nan 133152 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(nrows=2, 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) segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) fig, (ax1, ax2) = plt.subplots(nrows=2, 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:: >>> 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 sky_centroid ... kron_flux kron_flux_err flags ... ----- ---------- ---------- ------------ ... --------- ------------- ------ 1 235.38 1.44 None ... 490.35 nan 10256 5 257.85 12.18 None ... 748.18 nan 0 20 347.17 66.45 None ... 855.34 nan 2048 50 381.02 174.67 None ... 438.55 nan 0 75 74.44 259.78 None ... 876.02 nan 2048 80 14.93 60.06 None ... 878.52 nan 131104 By default, the :meth:`~photutils.segmentation.SourceCatalog.to_table` method includes only a small subset of source properties. The output table properties can be customized in the `~astropy.table.QTable` using the ``columns`` keyword:: >>> 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:: >>> 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 ^^^^^^^^^^^^^^^^^^ The ``error`` array input to :class:`~photutils.segmentation.SourceCatalog` must be 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:: >>> 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.35462 14.167067 5 257.82267 12.228232 489.96532 18.998371 20 347.15384 66.417567 625.96682 22.475065 50 380.94448 174.57181 249.01701 15.261334 75 74.413068 259.76066 836.4803 17.193721 80 14.920217 60.024006 666.60139 19.605394 The 1-sigma centroid position errors are also calculated when a total ``error`` is input, via the `~photutils.segmentation.SourceCatalog.centroid_err`, `~photutils.segmentation.SourceCatalog.centroid_win_err`, and `~photutils.segmentation.SourceCatalog.centroid_quad_err` properties and their per-axis `~photutils.segmentation.SourceCatalog.x_centroid_err`, `~photutils.segmentation.SourceCatalog.y_centroid_err`, `~photutils.segmentation.SourceCatalog.x_centroid_win_err`, `~photutils.segmentation.SourceCatalog.y_centroid_win_err`, `~photutils.segmentation.SourceCatalog.x_centroid_quad_err`, and `~photutils.segmentation.SourceCatalog.y_centroid_quad_err` equivalents:: >>> columns = ['label', 'x_centroid', 'x_centroid_err', 'y_centroid', ... 'y_centroid_err'] >>> tbl6 = cat_subset.to_table(columns=columns) >>> for col in tbl6.colnames: ... tbl6[col].info.format = '%.8g' # for consistent table output >>> print(tbl6) label x_centroid x_centroid_err y_centroid y_centroid_err ----- ---------- -------------- ---------- -------------- 1 235.24302 0.10078987 1.1928271 0.043716311 5 257.82267 0.092017708 12.228232 0.11001639 20 347.15384 0.11928677 66.417567 0.088327137 50 380.94448 0.13065541 174.57181 0.11247878 75 74.413068 0.051327718 259.76066 0.045532249 80 14.920217 0.069001032 60.024006 0.093764369 When a ``wcs`` is also input, the corresponding sky position errors are available in arcsec via the `~photutils.segmentation.SourceCatalog.sky_centroid_err`, `~photutils.segmentation.SourceCatalog.sky_centroid_ra_err`, and `~photutils.segmentation.SourceCatalog.sky_centroid_dec_err` properties (and their ``_win`` and ``_quad`` variants). The RA errors are expressed as great-circle angles along the East direction (i.e., they include the cos(dec) factor). A ``wcs`` also enables the `~photutils.segmentation.SourceCatalog.sky_orientation` property, the position angle on the sky of the source major axis measured from North toward East. It transports the pixel `~photutils.segmentation.SourceCatalog.covariance` matrix through the local WCS Jacobian, so it accounts for the WCS rotation, parity, and distortion. 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 nonfinite ``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:: >>> 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``. Quality Flags ------------- `~photutils.segmentation.SourceCatalog` provides per-source bitwise quality flags via its ``flags`` attribute (also included in the default table columns). The flags combine deblending provenance recorded by :func:`~photutils.segmentation.deblend_sources` (e.g., whether a source was produced by deblending, or whether its deblending mode fell back to another mode) with measurement-time conditions: the source touches an image boundary, masked or nonfinite pixels within the segment, undefined or degenerate shape properties, too few local background pixels, and Kron-aperture issues (``kron_``-prefixed flags). Accessing ``flags`` computes the flagged quantities (moments, covariance, local background, and Kron photometry) if they have not already been computed. The results are cached and shared with the corresponding source properties. The windowed and quadratic centroid fallbacks are reported by the `~photutils.segmentation.SourceCatalog.centroid_win_fallback` and `~photutils.segmentation.SourceCatalog.centroid_quad_fallback` properties instead of by flags. Flags that describe the same condition as an `~photutils.aperture.ApertureStats` flag, with the source segment as the region, use the same flag name. However, the bit values are package-specific, so always decode flag values with :func:`~photutils.segmentation.decode_segmentation_flags`. The :meth:`~photutils.segmentation.SourceCatalog.decode_flags` convenience method decodes the catalog's own flag values into a dictionary keyed by source label:: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.segmentation import SourceCatalog, detect_sources >>> yy, xx = np.mgrid[0:51, 0:51] >>> data = (Gaussian2D(100, 25, 25, 3, 3)(xx, yy) ... + Gaussian2D(100, 2, 40, 3, 3)(xx, yy)) >>> segm = detect_sources(data, 10, 5) >>> cat = SourceCatalog(data, segm) >>> for label, names in cat.decode_flags().items(): ... print(label, names) 1 [] 2 ['edge_touch', 'kron_partial_overlap'] The ``kron_minimum_radius`` flag (bit value 2048) is commonly set and does not indicate a problem with the measurement. It marks sources whose measured unscaled Kron radius was below the minimum value (``kron_params[1]``, 1.4 by default), so that the minimum was used to define the Kron aperture. The unscaled Kron radius of a Gaussian source is about 1.25, so compact sources are routinely flagged. For these sources, `~photutils.segmentation.SourceCatalog.kron_radius` is the minimum value and the Kron aperture remains an ellipse. The measured value is available in `~photutils.segmentation.SourceCatalog.kron_radius_measured`. The flag is also set where the minimum circular aperture (``kron_params[2]``) was used instead of the Kron ellipse, in which case `~photutils.segmentation.SourceCatalog.kron_radius` is zero. Memory Usage ------------ Most `~photutils.segmentation.SourceCatalog` properties are calculated for all sources at once in compiled code. That code reads the input ``data``, ``error``, ``background``, and ``convolved_data`` arrays and the segmentation image directly, without making copies, when: * the image arrays are C-contiguous, have native byte order, and are all ``float32`` or all ``float64``, and * the segmentation array is C-contiguous ``int32`` or `numpy.intp` with native byte order (e.g., as returned by :func:`~photutils.segmentation.detect_sources`). All calculations are performed in ``float64`` regardless of the input dtype, so ``float32`` inputs give the same results as the same values input as ``float64`` (to within floating-point rounding), while using half the memory. For a 4088 x 4088 ``float32`` image with ``error`` and ``convolved_data`` arrays and about 4600 sources, the catalog then holds about 50 MB of working memory (a 1 byte per pixel mask and the per-source results). If the image arrays have different dtypes from each other (e.g., ``float32`` data with a ``float64`` error array), or another dtype such as an integer dtype, they are converted to ``float64`` working copies that need 8 bytes per pixel each. The copies are created the first time they are needed and are cached for the lifetime of the catalog, so that they are shared by all of the source properties. The simplest way to avoid the copies is to input all of the image arrays with the same dtype. Note that arrays read from a FITS file with `astropy.io.fits` have big-endian byte order (e.g., a ``>f4`` dtype for ``float32`` data), which the compiled code does not read directly. Big-endian image arrays are converted to native byte-order working copies of the same dtype (4 bytes per pixel for ``float32`` and 8 bytes per pixel for ``float64``), and a big-endian ``int32`` segmentation array is converted to a native ``int32`` copy. Arithmetic on the arrays (e.g., subtracting a background) returns native byte-order arrays, but :func:`~astropy.convolution.convolve` preserves the input byte order. Also, some operations return ``float64`` arrays for ``float32`` input (e.g., :func:`~astropy.convolution.convolve_fft`), which then causes all of the image arrays to be converted to ``float64`` copies. To avoid all of these copies, convert the image arrays to native byte order once before using them, keeping their dtype (``np.float32`` for ``float32`` FITS data and ``np.float64`` for ``float64`` FITS data): .. doctest-skip:: >>> from astropy.io import fits >>> data = fits.getdata('image.fits').astype(np.float32) >>> error = fits.getdata('error.fits').astype(np.float32) >>> convolved_data = convolve(data, kernel) # native float32 If memory is a concern and the copies cannot be avoided, call the :meth:`~photutils.segmentation.SourceCatalog.release_cache` method after calculating the properties that you need:: >>> cat = SourceCatalog(data, segm) >>> tbl = cat.to_table() >>> cat.release_cache() This is useful when the catalog object stays alive while other memory-intensive work is performed. It is not needed for a catalog that is about to be deleted or go out of scope, because the cache is freed along with the catalog. Source properties that were already calculated are unaffected, and the working arrays are recreated on demand if another property is later requested. For that reason, avoid calling the method between property calculations. A catalog input as the ``detection_catalog`` has its own cache. API Reference ------------- :doc:`../reference/segmentation_api` .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ astropy-photutils-24be529/docs/user_guide/utils.rst000066400000000000000000000031541526452531300225700ustar00rootroot00000000000000Utility 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.compute_pixel_areas`: Function to compute the on-sky area of the pixels at given positions from a WCS. * :func:`~photutils.utils.compute_pixel_area_map`: Function to compute the on-sky area of every pixel in an image from a WCS. * :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.DeblendWarning`: Warning class to indicate issues encountered while deblending sources. * :class:`~photutils.utils.NoDetectionsWarning`: Warning class to indicate no sources were detected. API Reference ------------- :doc:`../reference/utils_api` astropy-photutils-24be529/docs/whats_new/000077500000000000000000000000001526452531300205375ustar00rootroot00000000000000astropy-photutils-24be529/docs/whats_new/1.1.rst000066400000000000000000000047071526452531300216000ustar00rootroot00000000000000.. 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`` and ``LegacySourceCatalog`` classes. The ``source_properties`` function and ``SourceProperties`` class are now deprecated and will eventually be removed. The ``source_properties`` function now returns a ``LegacySourceCatalog`` object (deprecated) to distinguish it from the new `~photutils.segmentation.SourceCatalog`. Optional keyword arguments in `~photutils.segmentation.SourceCatalog` cannot be input as positional arguments. Please see the `~photutils.segmentation.SourceCatalog` documentation for the keyword 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-24be529/docs/whats_new/1.10.rst000066400000000000000000000030631526452531300216520ustar00rootroot00000000000000.. 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 ``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-24be529/docs/whats_new/1.11.rst000066400000000000000000000041121526452531300216470ustar00rootroot00000000000000.. 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' mode. New ``include_localbkg`` Keyword ================================ The PSF photometry ``make_model_image`` and ``make_residual_image`` methods no longer include the local background by default. Including it caused issues when the ``psf_shape`` regions of sources overlapped. 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-24be529/docs/whats_new/1.12.rst000066400000000000000000000003551526452531300216550ustar00rootroot00000000000000.. 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-24be529/docs/whats_new/1.13.rst000066400000000000000000000123511526452531300216550ustar00rootroot00000000000000.. 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`` functions 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 ``make_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`, 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``, ``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` function now support input arrays with units. This requires inputting a ``threshold`` value that also has units compatible with the input data array. Sources that have nonfinite 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 an ``unnormalize`` method to return the profile to the state before any ``normalize`` calls were run. astropy-photutils-24be529/docs/whats_new/1.2.rst000066400000000000000000000002571526452531300215750ustar00rootroot00000000000000.. doctest-skip-all **************************** What's New in Photutils 1.2? **************************** Please see the :ref:`changelog` for the complete list of changes. astropy-photutils-24be529/docs/whats_new/1.3.rst000066400000000000000000000002571526452531300215760ustar00rootroot00000000000000.. doctest-skip-all **************************** What's New in Photutils 1.3? **************************** Please see the :ref:`changelog` for the complete list of changes. astropy-photutils-24be529/docs/whats_new/1.4.rst000066400000000000000000000062651526452531300216040ustar00rootroot00000000000000.. 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 pixels 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 compatibility 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-24be529/docs/whats_new/1.5.rst000066400000000000000000000105401526452531300215740ustar00rootroot00000000000000.. 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` function 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 increases. Also, a new ``sinh`` multithresholding 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 its centroid. The cutouts are instances of a new `~photutils.utils.CutoutImage` class. PSF-Fitting Masks ================= The ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``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-24be529/docs/whats_new/1.6.rst000066400000000000000000000062431526452531300216020ustar00rootroot00000000000000.. 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 to the SourceExtractor ``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 ============================================== A ``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-24be529/docs/whats_new/1.7.rst000066400000000000000000000037021526452531300216000ustar00rootroot00000000000000.. 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 ``to_patches`` and ``plot_patches`` methods, which return or plot, 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-24be529/docs/whats_new/1.8.rst000066400000000000000000000017461526452531300216070ustar00rootroot00000000000000.. 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 nonuniform 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-24be529/docs/whats_new/1.9.rst000066400000000000000000000062241526452531300216040ustar00rootroot00000000000000.. 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 ``BasicPSFPhotometry`` and ``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 (``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``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 (``DAOGroup`` and ``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 multidetector ePSF grids. Note that it is merely a container for STDPSF files. It cannot be used as a PSF model in the photometry classes. astropy-photutils-24be529/docs/whats_new/2.0.rst000066400000000000000000000303311526452531300215700ustar00rootroot00000000000000.. 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 an 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 PRF 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 nonlinear 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 the 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**31 - 1) labels are needed. Also, the ``relabel_consecutive``, ``reassign_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 ``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-24be529/docs/whats_new/2.1.rst000066400000000000000000000060271526452531300215760ustar00rootroot00000000000000.. 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-24be529/docs/whats_new/2.2.rst000066400000000000000000000041121526452531300215700ustar00rootroot00000000000000.. 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.Regions.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-24be529/docs/whats_new/2.3.rst000066400000000000000000000116701526452531300216000ustar00rootroot00000000000000.. 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 nonfinite 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 noncontiguous 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 nonfinite values. Breaking Changes ---------------- The ``GriddedPSFModel`` ``data`` and ``grid_xypos`` attributes are now read-only to prevent accidental modification. Deprecations and Removals ------------------------- - The ``PSFPhotometry`` ``fit_params`` attribute is now deprecated. Use the ``results`` attribute instead. - The deprecated ``PSFPhotometry`` ``fit_results`` attribute has been removed. astropy-photutils-24be529/docs/whats_new/3.0.rst000066400000000000000000001107561526452531300216030ustar00rootroot00000000000000.. 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 - Astropy minimum version is now 6.1.4 - 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 EPSFBuildResults Class -------------------------- :class:`~photutils.psf.EPSFBuilder` now returns an :class:`~photutils.psf.EPSFBuildResults` dataclass instead of a plain tuple. The result object provides structured access to detailed build diagnostics. This class was named ``EPSFBuildResult`` in 3.0 and was renamed to ``EPSFBuildResults`` in 3.1:: >>> 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.EPSFBuildResults`. 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 in the range [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_groups_object=True`` keyword argument:: >>> from photutils.psf import SourceGrouper >>> grouper = SourceGrouper(min_separation=10) >>> groups = grouper(x, y, return_groups_object=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 Nonfinite Local Background Values in PSF Photometry ------------------------------------------------------------------------ :class:`~photutils.psf.PSFPhotometry` and :class:`~photutils.psf.IterativePSFPhotometry` now gracefully handle nonfinite (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 nonfinite local background value is encountered: 1. The actual nonfinite 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 nonfinite fitted parameters (positions or flux) 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 to 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) * ``flux_radius`` (~6x speedup) * ``kron_flux`` * ``kron_flux_err`` * ``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 an 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 nonsquare 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 favor 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: The column name 'xcentroid' was ... # deprecated in version 3.0. Use 'x_centroid' instead. ... 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 in to 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 in to 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`` * ``grad_error`` -> ``gradient_err`` * ``grad_r_error`` -> ``gradient_rel_err`` The :class:`~photutils.isophote.EllipseSample` ``gradient_error`` and ``gradient_relative_error`` attributes have been renamed to ``gradient_err`` and ``gradient_rel_err``, respectively. 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`` (``SourceCatalog``) * ``data_ma`` -> ``data_masked`` (``SegmentationImage`` and ``Segment``) * ``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. The previously deprecated ``IntegratedGaussianPRF`` and ``PRFAdapter`` classes have also been removed. .. _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 nonzero 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. Other Changes ^^^^^^^^^^^^^ * :func:`~photutils.detection.find_peaks` now returns a `~astropy.table.QTable` instead of a `~astropy.table.Table`. * The default columns of the :meth:`~photutils.segmentation.SourceCatalog.to_table` output no longer include ``local_background``. * :class:`~photutils.psf.LinkedEPSFStar` no longer inherits from :class:`~photutils.psf.EPSFStars`. astropy-photutils-24be529/docs/whats_new/3.1.rst000066400000000000000000002266771526452531300216160ustar00rootroot00000000000000.. doctest-skip-all .. _whatsnew-3.1: **************************** What's New in Photutils 3.1? **************************** Here we highlight some of the new functionality of the 3.1 release. In addition to these changes, Photutils 3.1 includes several smaller improvements and bug fixes, which are described in the full :ref:`changelog`. .. contents:: :local: :depth: 2 Dependency Version Updates ========================== Photutils 3.1 bumps the minimum required versions of Python and several key dependencies to provide users with access to the latest features and performance improvements. The minimum required versions are now: - Python: 3.12 - Astropy: 7.0 - SciPy: 1.15 - Matplotlib: 3.10 - Regions: 0.10 - gwcs: 0.22 - Shapely: 2.1 - tqdm: 4.67 ASDF Serialization Support ========================== Photutils 3.1 introduces initial support for serializing photutils objects to the `Advanced Scientific Data Format (ASDF) `_ file format. This requires installing the ``asdf`` package. Objects that store an `~astropy.units.Quantity` or a `~astropy.coordinates.SkyCoord` also require the optional ``asdf-astropy`` package, which provides the converters for those Astropy types. That includes all of the PSF models, all of the sky apertures, and any pixel aperture whose rotation angle is given as an angular quantity. >>> import asdf >>> from photutils.aperture import CircularAperture >>> aperture = CircularAperture((10, 20), r=5) >>> with asdf.AsdfFile() as af: ... af['aperture'] = aperture ... af.write_to('aperture.asdf') >>> with asdf.open('aperture.asdf') as af: ... aperture = af['aperture'] The following classes can be serialized to and deserialized from ASDF files: Apertures * `~photutils.aperture.CircularAnnulus` * `~photutils.aperture.CircularAperture` * `~photutils.aperture.EllipticalAnnulus` * `~photutils.aperture.EllipticalAperture` * `~photutils.aperture.PolygonAperture` * `~photutils.aperture.RectangularAnnulus` * `~photutils.aperture.RectangularAperture` * `~photutils.aperture.SkyCircularAnnulus` * `~photutils.aperture.SkyCircularAperture` * `~photutils.aperture.SkyEllipticalAnnulus` * `~photutils.aperture.SkyEllipticalAperture` * `~photutils.aperture.SkyPolygonAperture` * `~photutils.aperture.SkyRectangularAnnulus` * `~photutils.aperture.SkyRectangularAperture` PSF Models * `~photutils.psf.AiryDiskPRF` * `~photutils.psf.AiryDiskPSF` * `~photutils.psf.CircularGaussianPRF` * `~photutils.psf.CircularGaussianPSF` * `~photutils.psf.CircularGaussianSigmaPRF` * `~photutils.psf.GaussianPRF` * `~photutils.psf.GaussianPSF` * `~photutils.psf.MoffatPRF` * `~photutils.psf.MoffatPSF` * `~photutils.psf.GriddedPSFModel` * `~photutils.psf.ImagePSF` * `~photutils.psf.STDPSFGrid` See :ref:`asdf` for more details. Exact Rectangular Aperture Overlap ================================== Rectangular apertures used for aperture photometry and related statistical measurements now support the ``method='exact'`` option for computing pixel-aperture overlap fractions. Previously, requesting ``method='exact'`` for a `~photutils.aperture.RectangularAperture` or `~photutils.aperture.RectangularAnnulus` was silently translated to a 32x subpixel approximation. The new implementation computes the exact analytic overlap area using a Sutherland-Hodgman polygon-clipping algorithm. New ``AperturePhotometry`` Class ================================ Photutils 3.1 introduces a new `~photutils.aperture.AperturePhotometry` class, which is now the recommended tool for aperture photometry. Like `~photutils.aperture.ApertureStats`, the data, apertures, and options are supplied to the constructor, and the results are exposed as lazily computed attributes or as an Astropy `~astropy.table.QTable` via the ``to_table`` method:: >>> import numpy as np >>> from photutils.aperture import AperturePhotometry, CircularAperture >>> data = np.ones((100, 100)) >>> aperture = CircularAperture([(30.0, 30.0), (40.0, 40.0)], r=5.0) >>> phot = AperturePhotometry(data, aperture) >>> fluxes = phot.flux # lazily computed >>> phot_table = phot.to_table() The class reports the aperture :attr:`~photutils.aperture.AperturePhotometry.flux`, :attr:`~photutils.aperture.AperturePhotometry.flux_err`, unmasked overlap :attr:`~photutils.aperture.AperturePhotometry.area`, and bitwise quality :attr:`~photutils.aperture.AperturePhotometry.flags` as attributes, all of which are included in the default ``to_table()`` output. Compared to the legacy :func:`~photutils.aperture.aperture_photometry` function, it adds the aperture areas and quality flags and supports segmentation-based neighbor masking (see below). The flux columns use the names ``'flux'`` and ``'flux_err'`` (rather than ``'aperture_sum'`` and ``'aperture_sum_err'``). The class is immutable after construction (aside from the internal lazy-attribute cache), so a single instance is safe to share across threads, and independent images can be processed concurrently (see :ref:`the thread-safety section ` below). Like `~photutils.aperture.ApertureStats`, the output attributes are scalars when a single scalar aperture position is input (e.g., ``CircularAperture((30.0, 30.0), r=5.0)``) and arrays when multiple positions are input. The legacy :func:`~photutils.aperture.aperture_photometry` function remains available and is not deprecated, but it is now considered a legacy interface. New features are added only to the `~photutils.aperture.AperturePhotometry` and `~photutils.aperture.ApertureStats` classes. Faster Aperture Photometry ========================== Aperture photometry is now significantly faster for all built-in aperture shapes. The photometry for all sources is now computed in a single call into compiled code, avoiding the per-source Python overhead of creating individual aperture masks and cutouts. Typical speedups range from ~2x to more than 25x, depending on the aperture shape and overlap method, with the largest gains for fields with many sources. Faster Aperture Statistics ========================== `~photutils.aperture.ApertureStats` is now significantly faster. The per-source statistics are computed in a single call into compiled code, reusing the same fast pixel-overlap machinery as the aperture photometry improvements above. Sigma-clipped statistics are also computed in compiled code for the common `~astropy.stats.SigmaClip` configurations. Typical speedups range from ~5x to ~15x, depending on which properties are requested. Aperture Statistics Error Estimates =================================== `~photutils.aperture.ApertureStats` now provides standard-error estimates for its location statistics via the new ``mean_err`` and ``median_err`` properties. The ``mean_err`` property returns the standard error of the mean, :math:`s / \sqrt{N}`, where :math:`s` is the sample standard deviation of the unmasked aperture pixels and :math:`N` is the number of those pixels. The ``median_err`` property returns the large-sample standard error of the median, :math:`\sqrt{\pi / 2} \, s / \sqrt{N} = \sqrt{\pi / (2 \, N)} \, s`, which assumes the pixel values are approximately normally distributed. Both properties are ``NaN`` for apertures with fewer than two unmasked pixels:: >>> import numpy as np >>> from photutils.aperture import ApertureStats, CircularAperture >>> rng = np.random.default_rng(seed=0) >>> data = rng.normal(100.0, 5.0, (100, 100)) >>> aper = CircularAperture((50, 50), r=10) >>> stats = ApertureStats(data, aper) >>> stats.mean_err np.float64(0.2744788180188647) Corrected Flux Errors for Partial Pixels ======================================== The propagated flux errors are now mathematically consistent with the reported fluxes. An aperture flux is the sum of the pixel values weighted by their aperture overlap fractions, :math:`F = \sum_i w_i f_i`, so its variance with independent pixel errors is :math:`\sum_i w_i^2 \sigma_i^2`. Previously, the pixel variances on the aperture boundary were weighted by the linear overlap fraction, which overestimates the flux error by a few percent for small and compact apertures (Monte Carlo simulations confirm that the new errors match the actual scatter of the measured fluxes). The corrected error propagation applies everywhere fractional pixel weights are combined with an input ``error`` array, including `~photutils.aperture.AperturePhotometry`, :func:`~photutils.aperture.aperture_photometry`, `~photutils.aperture.ApertureStats` (``sum_err`` and ``error_sum_cutout``), the `~photutils.segmentation.SourceCatalog` ``kron_flux_err`` and ``circular_photometry`` errors, and the ``profile_error`` values of the profile classes. The `~photutils.segmentation.SourceCatalog` ``segment_flux_err`` values are unchanged because source segments contain only whole pixels. Sample Variance and Standard Deviation ====================================== `~photutils.aperture.ApertureStats` now accepts a ``ddof`` keyword that sets the delta degrees of freedom used when computing the ``var`` and ``std`` properties. The divisor used in the calculation is :math:`N - \mathrm{ddof}`, where :math:`N` is the number of unmasked pixels within the aperture. The default is ``ddof=0``, which gives the population variance and standard deviation (unchanged from previous versions). Use ``ddof=1`` to obtain the sample (unbiased) variance and standard deviation:: >>> import numpy as np >>> from photutils.aperture import ApertureStats, CircularAperture >>> rng = np.random.default_rng(seed=0) >>> data = rng.normal(100.0, 5.0, (100, 100)) >>> aper = CircularAperture((50, 50), r=10) >>> stats = ApertureStats(data, aper, ddof=1) >>> stats.std np.float64(4.793566477062259) The ``ddof`` keyword affects only the ``var`` and ``std`` properties. All other properties are unaffected, including the ``mean_err`` and ``median_err`` standard errors, which are always computed using the sample standard deviation regardless of the value of ``ddof``. .. _whatsnew-3.1-thread-safe-photometry: Thread-Safe and Free-Threading-Compatible Aperture Photometry ============================================================= The new aperture photometry implementation releases the Python global interpreter lock (GIL) during the computation and uses no global state, making it safe to call concurrently from multiple threads. The compiled extensions are also declared compatible with free-threaded CPython builds, so threads can run the computation truly in parallel. The simplest way to use multiple threads is the new ``n_threads`` keyword of `~photutils.aperture.AperturePhotometry` and `~photutils.aperture.ApertureStats`. The aperture positions are divided into chunks and processed concurrently, producing results identical to the single-threaded computation:: >>> import numpy as np >>> from photutils.aperture import (AperturePhotometry, ... ApertureStats, CircularAperture) >>> rng = np.random.default_rng(seed=0) >>> data = rng.random((4096, 4096)) >>> positions = rng.uniform(0, 4096, (100_000, 2)) >>> aperture = CircularAperture(positions, r=5.0) >>> phot = AperturePhotometry(data, aperture, n_threads=8) >>> stats = ApertureStats(data, aperture, n_threads=8) The aperture sums, sigma clipping, and per-source statistic reductions (e.g., the sorts, order statistics, robust estimators, and moments) all run in the threads, so both classes scale nearly linearly with the thread count for large numbers of positions. For the example above, measured speedups are ~2x with 2 threads and ~8-10x with 12 threads, for the aperture fluxes and sums as well as for statistics such as the median, MAD standard deviation, and the biweight estimators. `~photutils.segmentation.SourceCatalog` also accepts an ``n_threads`` keyword. Its compiled per-source measurements (the isophotal moments, segment pixel statistics, windowed and quadratic centroids, perimeter, Kron and circular photometry, and flux radii) are computed in chunks of sources processed concurrently, with results identical to the single-threaded computation, so the default ``to_table`` output of a large catalog is several times faster with several threads:: >>> from photutils.datasets import make_100gaussians_image >>> from photutils.segmentation import SourceCatalog, detect_sources >>> image = make_100gaussians_image() - 5.0 # subtract background >>> segm = detect_sources(image, 9.0, n_pixels=5) >>> cat = SourceCatalog(image, segm, n_threads=8) >>> table = cat.to_table() For more advanced workflows, independent images can also be processed concurrently with a thread pool, one instance per image:: >>> from concurrent.futures import ThreadPoolExecutor >>> images = [rng.random((1024, 1024)) for _ in range(8)] >>> aperture = CircularAperture(rng.uniform(0, 1024, (1000, 2)), ... r=5.0) >>> with ThreadPoolExecutor(max_workers=8) as executor: ... results = list(executor.map( ... lambda image: AperturePhotometry(image, aperture), ... images)) >>> tables = [result.to_table() for result in results] Because ``executor.map`` returns results in input order, the tables are in the same order as the input ``images``. Multithreaded 2D Background Estimation ====================================== `~photutils.background.Background2D` now accepts an ``n_threads`` keyword to compute the box statistics using multiple threads. The boxes are divided into chunks and processed concurrently, producing results identical to the single-threaded computation. The underlying sigma-clipping and statistics kernels release the Python global interpreter lock (GIL), so the threads run in parallel:: >>> import numpy as np >>> from photutils.background import Background2D >>> rng = np.random.default_rng(seed=0) >>> data = rng.normal(100.0, 5.0, (4096, 4096)) >>> bkg = Background2D(data, (64, 64), n_threads=8) The ``n_threads`` keyword also multithreads the interpolation of the low-resolution meshes to the full-size ``background`` and ``background_rms`` maps (for the ``'reflect'`` and ``'mirror'`` interpolation boundary modes, with SciPy 1.16 or later). `~photutils.background.Background2D` is also significantly faster when the ``sigma_clip``, ``bkg_estimator``, and ``bkg_rms_estimator`` inputs are among the standard photutils options. The sigma clipping and the box statistics are then computed in a single combined pass in compiled code. Custom estimator callables use the previous implementation and give the same results as before. In addition, `~photutils.background.Background2D` instances are now immutable after construction (aside from internal lazy-attribute caches), so a single instance is safe to share across threads and independent images can be processed concurrently. Faster Local Backgrounds ======================== `~photutils.background.LocalBackground` is now significantly faster when the ``bkg_estimator`` is a standard photutils estimator class. The local backgrounds for all positions are computed with the batched `~photutils.aperture.ApertureStats` kernels instead of a Python loop over the apertures. A new ``n_threads`` keyword measures the local backgrounds using multiple threads, with results identical to the single-threaded computation. `~astropy.units.Quantity` input data is now also supported, and the result has the same unit as the input data. Faster and Thread-Safe Profiles =============================== The radial profile and curve-of-growth classes are now about 3x faster. The aperture photometry and unmasked areas for all radii are computed in a single batched `~photutils.aperture.AperturePhotometry` call instead of one calculation per radius. The profile classes are now also thread-safe. Faster PSF Fitting via Analytic Jacobians ========================================= The image-based PSF models `~photutils.psf.ImagePSF` and `~photutils.psf.GriddedPSFModel` now provide analytic Jacobians through their ``fit_deriv`` methods, computed from the derivatives of their interpolating splines. Astropy fitters use the Jacobian automatically during PSF photometry, removing the need for finite-difference approximations of the parameter derivatives. This eliminates roughly 60% of the model evaluations performed during fitting, speeding up PSF photometry with these models with no change in the fitted results. The pixel-integrated Gaussian models `~photutils.psf.GaussianPRF`, `~photutils.psf.CircularGaussianPRF`, and `~photutils.psf.CircularGaussianSigmaPRF`, and the `~photutils.psf.MoffatPSF` and `~photutils.psf.AiryDiskPSF` models, also provide analytic Jacobians. All of the PSF models in `photutils.psf` now have one, so the fitted positions and fluxes no longer depend on the step size of a finite-difference approximation. ``GriddedPSFModel`` and ``ImagePSF`` Performance Improvements ============================================================= `~photutils.psf.GriddedPSFModel` and `~photutils.psf.ImagePSF` evaluations are now significantly faster. Both models evaluate their interpolating splines, and the spline derivatives of their analytic Jacobians, with a compiled kernel instead of separate spline calls. For fit regions of 5x5 to 25x25 pixels, `~photutils.psf.GriddedPSFModel` evaluations are about 3 to 8 times faster and its Jacobian evaluations about 6 to 14 times faster. `~photutils.psf.ImagePSF` evaluations are about 1.2 to 3 times faster and its Jacobian evaluations about 2 to 5 times faster. This improves the runtime of PSF photometry workflows. The models also use less memory. They cache a single set of spline coefficients, which is about a third of the memory that the cached splines used when the Jacobian is evaluated. Defining a custom interpolator for `~photutils.psf.ImagePSF`, by overriding its ``interpolator`` attribute in a subclass or by assigning an interpolator to it, is now deprecated and will be removed in version 4.0. A model with a custom interpolator calls it as before and does not use the compiled kernel. Scalable Source Grouping ======================== `~photutils.psf.SourceGrouper` now computes groups using a KD-tree connected-components algorithm instead of building a dense pairwise distance matrix. The resulting groups are identical to the previous single-linkage clustering, but grouping now scales to very large source lists in both runtime and memory. Multithreaded PSF Photometry ============================ `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` now accept an ``n_threads`` keyword to fit the source groups using multiple threads. The groups are divided into chunks that are fitted concurrently, each thread with its own copy of the PSF model and the fitter, producing results identical to the single-threaded computation:: >>> from photutils.psf import CircularGaussianPRF, PSFPhotometry >>> psf_model = CircularGaussianPRF(fwhm=2.7) >>> psfphot = PSFPhotometry(psf_model, (5, 5), n_threads=8) Unlike the compiled aperture and background code, the PSF fitting runs mostly in Python code that holds the Python global interpreter lock (GIL). The threads therefore run in parallel only on a free-threaded CPython build, where measured speedups are ~1.8x with 2 threads and ~5x with 8 threads. When the GIL is enabled, threads would be slower than a single thread, so a warning is issued and the sources are fitted in a single thread. PSF Photometry Fit Region Flag ============================== `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` have a new bitwise flag (bit 4096, ``position_outside_fit_region``) that is set when the fitted source position is outside the region of fitted data. The region is the ``fit_shape`` box around the initial position, trimmed to the input image. Such a fit is constrained only by the PSF wings, often because it moved toward a neighboring source. Unlike the ``position_outside_image`` flag (bit 2), which is set only for positions outside the input image, this flag also catches fits that move away from their data within the image. For grouped fits, the region is the box of each source, not the union of the boxes of the group. Making an ePSF from a Sampled PSF ================================= The new `~photutils.psf.make_epsf_from_psf` function makes an effective PSF (ePSF) image from an oversampled PSF image whose values are samples of the PSF, such as the output of an optical model. The image-based PSF models `~photutils.psf.ImagePSF` and `~photutils.psf.GriddedPSFModel` interpolate their input images and do not integrate them over the detector pixels. They therefore require ePSF images, in which each value is the flux in a whole detector pixel centered at that position. A model made from a sampled PSF is sharper than the sources in the data. If the PSF is also undersampled by the detector pixels, the total flux of the evaluated model changes with the subpixel position of the source. The new function integrates the image over the area of a detector pixel centered at each grid point, which gives a model that conserves flux:: >>> from photutils.psf import ImagePSF, make_epsf_from_psf >>> epsf = make_epsf_from_psf(psf, oversampling=4) # doctest: +SKIP >>> model = ImagePSF(epsf, oversampling=4) # doctest: +SKIP An optical model with an even oversampling factor typically returns an image with an even number of points, with the PSF centered between the four central grid points. The ``midpoints=True`` option makes the ePSF at the points midway between the input grid points. The result has an odd number of points with the PSF center on its central grid point, like the ePSFs made by `~photutils.psf.EPSFBuilder`. Pixel-Integrated Moffat and Airy Disk Models ============================================ The new `~photutils.psf.MoffatPRF` and `~photutils.psf.AiryDiskPRF` models are the Moffat and Airy disk profiles integrated over the detector pixels. They have the same parameters as `~photutils.psf.MoffatPSF` and `~photutils.psf.AiryDiskPSF`, which are evaluated at the pixel centers. A model that is evaluated at the pixel centers is sharper than the image of a source, so it gives biased fluxes and positions when it is fit to the pixel values of an image. The bias is a few percent for a FWHM of 2 to 3 pixels and tens of percent for an undersampled PSF, where it also depends on the subpixel position of the source. The new models remove that bias and their shape parameters can be fit. Every analytic PSF model in `photutils.psf` now has a pixel-integrated counterpart, which should be used for PSF photometry. The integral over each pixel is computed with Gauss-Legendre quadrature. The number of nodes along each axis of a pixel is set by the ``n_nodes`` keyword. With the default of 9 nodes, the new models are about 10 times slower to evaluate than `~photutils.psf.MoffatPSF` and `~photutils.psf.AiryDiskPSF` on the small cutouts used for PSF fitting. Polygon Apertures ================= Two new aperture classes have been added: `~photutils.aperture.PolygonAperture` and `~photutils.aperture.SkyPolygonAperture`. Like the other aperture types, they have a single fixed shape (parameterized by ``vertex_offsets``) that can be applied at one or more positions:: >>> import numpy as np >>> from photutils.aperture import PolygonAperture >>> positions = [(10.0, 20.0), (30.0, 40.0)] >>> # A regular hexagon centered on each position >>> outer_radius = 5.0 >>> theta = np.linspace(0.0, 2 * np.pi, 6, endpoint=False) >>> offsets = np.column_stack([outer_radius * np.cos(theta), ... outer_radius * np.sin(theta)]) >>> aper = PolygonAperture(positions, offsets) The polygon must be simple (non-self-intersecting), but it does not need to be convex. Nonconvex shapes such as L-shapes or stars are fully supported. Regular polygons can be constructed by specifying a center, the number of vertices, the `circumradius (outer radius) `_, and an optional rotation angle via the ``from_regular_polygon`` classmethod. The ``is_regular`` property reports whether a polygon is regular, and the ``outer_radius``, ``inner_radius``, ``side_length``, ``interior_angle``, ``exterior_angle``, and ``theta`` properties expose the geometric parameters of regular polygons (they raise ``ValueError`` for nonregular polygons):: >>> hexagon = PolygonAperture.from_regular_polygon((10.0, 20.0), ... n_vertices=6, ... radius=5.0) >>> hexagon.is_regular True >>> hexagon.outer_radius 5.0 >>> hexagon.interior_angle A ``from_vertices`` classmethod is also provided as a convenience for building a single polygon directly from absolute pixel or sky vertex coordinates:: >>> verts = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0)] >>> aper = PolygonAperture.from_vertices(verts) Both polygon aperture classes also expose a ``vertices`` attribute returning the absolute vertex coordinates at every aperture position. Conversions between `~photutils.aperture.PolygonAperture` and `~photutils.aperture.SkyPolygonAperture` use ``wcs.pixel_to_world`` and ``wcs.world_to_pixel`` directly on the absolute vertex coordinates, so the conversion captures the actual finite-displacement WCS transformation of the polygon corners. ``aperture_to_region`` and ``region_to_aperture`` also now support `~regions.PolygonPixelRegion` and `~regions.PolygonSkyRegion`. Converting Apertures to Polygons ================================ The circular, elliptical, and rectangular apertures (both their pixel and sky variants) now have a ``to_polygon`` method that converts them to an equivalent `~photutils.aperture.PolygonAperture` or `~photutils.aperture.SkyPolygonAperture`. The circular and elliptical apertures are approximated by a polygon with a configurable number of vertices (``n_vertices``, default 100), while the rectangular aperture is converted exactly to a four-vertex polygon:: >>> from photutils.aperture import CircularAperture >>> aper = CircularAperture((10.0, 20.0), r=5.0) >>> poly = aper.to_polygon(n_vertices=100) Segmentation-Based Masking in Aperture Photometry ================================================= `~photutils.aperture.AperturePhotometry` and `~photutils.aperture.ApertureStats` now accept ``segmentation_image``, ``labels``, and ``mask_method`` keywords to mask or correct the flux from neighboring sources that overlap an aperture. The ``segmentation_image`` must be a `~photutils.segmentation.SegmentationImage` (see :ref:`Image Segmentation `) that labels each source with a positive integer, and the ``mask_method`` keyword selects how neighboring sources are handled: * ``'none'`` (default): the segmentation image is ignored. * ``'mask'``: pixels belonging to neighboring sources are excluded. * ``'source_only'``: only pixels belonging to the target source are included. * ``'background_only'``: pixels belonging to any source are excluded, e.g., to estimate the local background within an annulus aperture. * ``'correct'``: neighboring-source pixels are replaced by the values mirrored across the aperture center (excluded if the mirror pixel is unavailable). It is required to explicitly provide the source ``labels`` for each aperture when a ``segmentation_image`` is input and ``mask_method`` is ``'mask'``, ``'source_only'``, or ``'correct'`` (the ``'background_only'`` method does not use the labels). The ``labels`` keyword must have the same length as the number of aperture positions and identifies the target source for each aperture. Requiring explicit labels ensures the correct target source is used for each aperture, which is especially important for irregularly shaped source segmentation footprints or for apertures that are not centered on a source. Example of using the new segmentation-based masking:: >>> import numpy as np >>> from photutils.aperture import AperturePhotometry, CircularAperture >>> from photutils.segmentation import SegmentationImage >>> data = np.ones((11, 11)) >>> data[4:7, 4:7] = 10.0 # target source >>> data[4:7, 7:10] = 50.0 # bright neighbor >>> segm = np.zeros((11, 11), dtype=int) >>> segm[4:7, 4:7] = 1 >>> segm[4:7, 7:10] = 2 >>> segm = SegmentationImage(segm) >>> aperture = CircularAperture((5, 5), r=4) >>> phot = AperturePhotometry(data, aperture, segmentation_image=segm, ... labels=1, mask_method='mask') .. _whatsnew-3.1-aperture-flags: Aperture Photometry Quality Flags ================================= Aperture photometry and statistics now provide per-source bitwise quality flags, unified across the `~photutils.aperture.AperturePhotometry` and `~photutils.aperture.ApertureStats` classes (e.g., a new :attr:`~photutils.aperture.ApertureStats.flags` property, included in the default ``to_table()`` columns). In `~photutils.aperture.ApertureStats`, ``flags`` reports conditions from both the ``'center'``-method footprint used by the value statistics and the ``sum_method`` footprint used by the sum properties. A flag value of 0 means that no issues were detected. The flags indicate conditions such as: * the aperture is partially (``'partial_overlap'``) or fully (``'no_overlap'``) outside the data * the aperture contains no pixels with nonzero weight (``'no_pixels'``) * input-masked pixels within the aperture (``'masked_pixels'``) * nonfinite data or error values within the aperture (``'non_finite_data'``, ``'non_finite_error'``) * pixels excluded or corrected due to neighboring sources in a segmentation image (``'neighbor_pixels'``, ``'uncorrected_pixels'``) * no valid pixels at all, because every pixel is masked, nonfinite, or excluded (``'all_masked'``) * sigma-clipped pixels within the aperture (``'sigma_clipped'``, ``'all_clipped'``) and too few pixels to compute a statistic (``'too_few_pixels'``) in `~photutils.aperture.ApertureStats` * sources whose net flux is not positive or whose second-order moments are not positive semidefinite (``'undefined_shape'``), whose covariance matrix is singular or nearly singular (``'singular_covariance'``), or whose centroid lies outside the aperture bounding box (``'centroid_outside_bbox'``), so that the centroid and shape properties are undefined or unreliable, in `~photutils.aperture.ApertureStats` The overlap flags are precise. They are based on the pixels with nonzero aperture weight, not simply the aperture bounding box. The new :func:`~photutils.aperture.decode_aperture_flags` function and the :meth:`photutils.aperture.AperturePhotometry.decode_flags` and :meth:`photutils.aperture.ApertureStats.decode_flags` methods (which return a dictionary keyed by aperture position ID) decode flag values into human-readable names:: >>> import numpy as np >>> from photutils.aperture import (AperturePhotometry, ... CircularAperture, ... decode_aperture_flags) >>> data = np.ones((25, 25)) >>> mask = np.zeros(data.shape, dtype=bool) >>> mask[12, 12] = True # bad pixel inside the first aperture >>> positions = [(12.0, 12.0), (0.0, 12.0), (-50.0, 12.0)] >>> aperture = CircularAperture(positions, r=3.0) >>> phot = AperturePhotometry(data, aperture, mask=mask) >>> decoded = decode_aperture_flags(phot.flags) >>> for source_id, names in zip(phot.id, decoded): ... print(source_id, names) 1 ['masked_pixels'] 2 ['partial_overlap'] 3 ['no_overlap', 'no_pixels'] See :ref:`aperture_flags` for more details. Centroid Position Errors ======================== `~photutils.segmentation.SourceCatalog` now provides 1-sigma errors on the isophotal, windowed, and quadratic centroid positions, propagated from the input ``error`` array. The new properties are `~photutils.segmentation.SourceCatalog.centroid_err`, `~photutils.segmentation.SourceCatalog.x_centroid_err`, and `~photutils.segmentation.SourceCatalog.y_centroid_err` for the isophotal `~photutils.segmentation.SourceCatalog.centroid`, `~photutils.segmentation.SourceCatalog.centroid_win_err`, `~photutils.segmentation.SourceCatalog.x_centroid_win_err`, and `~photutils.segmentation.SourceCatalog.y_centroid_win_err` for the "windowed" centroid (`~photutils.segmentation.SourceCatalog.centroid_win`), and `~photutils.segmentation.SourceCatalog.centroid_quad_err`, `~photutils.segmentation.SourceCatalog.x_centroid_quad_err`, and `~photutils.segmentation.SourceCatalog.y_centroid_quad_err` for the quadratic-fit centroid (`~photutils.segmentation.SourceCatalog.centroid_quad`):: >>> import numpy as np >>> from astropy.convolution import convolve >>> from photutils.datasets import make_100gaussians_image >>> from photutils.segmentation import (SourceCatalog, SourceFinder, ... make_2dgaussian_kernel) >>> data = make_100gaussians_image() - 5.0 # subtract background >>> error = np.full(data.shape, 2.0) >>> kernel = make_2dgaussian_kernel(3.0, size=5) >>> convolved_data = convolve(data, kernel) >>> finder = SourceFinder(n_pixels=10) >>> segment_map = finder(convolved_data, 4.5) >>> cat = SourceCatalog(data, segment_map, ... convolved_data=convolved_data, error=error) >>> columns = ['label', 'x_centroid_err', 'y_centroid_err', ... 'x_centroid_win_err', 'y_centroid_win_err'] >>> tbl = cat.to_table(columns=columns)[:5] >>> for col in tbl.colnames[1:]: ... tbl[col].info.format = '%.6f' >>> print(tbl) label x_centroid_err y_centroid_err x_centroid_win_err y_centroid_win_err ----- -------------- -------------- ------------------ ------------------ 1 0.107193 0.040633 0.096188 0.081614 2 0.115570 0.069543 0.145871 0.146489 3 0.083684 0.087086 0.144631 0.144630 4 0.078204 0.104136 0.158468 0.158468 5 0.081175 0.098188 0.181951 0.181947 When a ``wcs`` is also input, the corresponding 1-sigma sky position errors are available in arcsec via the `~photutils.segmentation.SourceCatalog.sky_centroid_err`, `~photutils.segmentation.SourceCatalog.sky_centroid_ra_err`, and `~photutils.segmentation.SourceCatalog.sky_centroid_dec_err` properties (and their ``_win`` and ``_quad`` variants), computed by transporting the pixel centroid error covariance through the local WCS Jacobian. The RA errors are expressed as great-circle angles along the East direction (i.e., they include the cos(dec) factor). Sky Orientation of Sources ========================== `~photutils.segmentation.SourceCatalog` and `~photutils.aperture.ApertureStats` have new `~photutils.segmentation.SourceCatalog.sky_orientation` and `~photutils.aperture.ApertureStats.sky_orientation` properties giving the position angle on the sky of the major axis of each source, measured from North toward East in the range (-90, 90] degrees. It is computed by transporting the pixel `~photutils.segmentation.SourceCatalog.covariance` matrix to the local tangent plane with the WCS Jacobian at each source centroid, so it accounts for the WCS rotation, parity, and local distortion:: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.datasets import make_wcs >>> from photutils.segmentation import SourceCatalog, detect_sources >>> yy, xx = np.mgrid[0:31, 0:31] >>> theta = np.deg2rad(70.0) >>> data = Gaussian2D(500.0, 15.0, 15.0, 3.0, 1.5, theta)(xx, yy) >>> segm = detect_sources(data, 10.0, n_pixels=10) >>> wcs = make_wcs(data.shape) >>> cat = SourceCatalog(data, segm, wcs=wcs) >>> print(cat.orientation.to_value('deg').round(2)) [70.49] >>> print(cat.sky_orientation.to_value('deg').round(2)) [40.49] Segmentation Quality Flags ========================== The segmentation package now provides bitwise quality flags. `~photutils.segmentation.SourceCatalog` has a new ``flags`` attribute and default table column that records, per source, deblending provenance (``deblended``, deblending-mode fallbacks) and measurement conditions (source touches an image boundary, masked or nonfinite pixels within the segment, undefined or degenerate shape properties, Kron-aperture issues, and a local background with too few usable annulus pixels). `~photutils.segmentation.SegmentationImage` exposes the per-label provenance flags via a new ``flags`` attribute, and a new :func:`~photutils.segmentation.decode_segmentation_flags` function decodes flag values into names. The flag definitions are available in the new public ``SEGMENTATION_FLAGS`` registry. :meth:`~photutils.segmentation.SourceCatalog.decode_flags` is a convenience method that decodes the catalog's own flag values into a dictionary keyed by source label. Flags describing the same condition as an aperture flag share the aperture flag's name. However, the bit values are package-specific, so always decode a flag column with the decoder of the package that produced it. The new `~photutils.segmentation.SourceCatalog.centroid_win_fallback` and `~photutils.segmentation.SourceCatalog.centroid_quad_fallback` properties report where the windowed or quadratic centroid was replaced by the isophotal centroid or the peak pixel position. The ``kron_minimum_radius`` flag marks sources whose `~photutils.segmentation.SourceCatalog.kron_radius` was set to the minimum Kron radius or replaced by the minimum circular aperture. This is common for compact sources because the unscaled Kron radius of a Gaussian source is about 1.25, which is below the default minimum of 1.4. The new `~photutils.segmentation.SourceCatalog.kron_radius_measured` property returns the measured Kron radius before the ``kron_params`` minimum values are applied. Faster Source Catalog Measurements ================================== `~photutils.segmentation.SourceCatalog` is now significantly faster. The windowed and quadratic centroids, Kron radii and photometry, ``flux_radius``, ``circular_photometry``, image moments, ``perimeter``, ``gini``, the segment pixel statistics (e.g., ``segment_flux``, ``area``, and ``min_value``), and the positions of the minimum and maximum pixel values are each computed for all sources in a single call into compiled code, replacing the per-source Python loops and cutouts. Typical speedups range from ~3x to ~20x depending on the property, and the ``flags`` attribute and the default :meth:`~photutils.segmentation.SourceCatalog.to_table` output are about 5x faster for large catalogs. The results are unchanged to within floating-point rounding. Because no property loops over the sources in Python anymore, no progress bar is displayed and the ``progress_bar`` keyword is deprecated (see :ref:`New Deprecations `). The compiled code reads the input arrays directly when they share a common ``float32`` or ``float64`` dtype. Otherwise, it makes full-image working copies that are cached for the lifetime of the catalog. The new :meth:`~photutils.segmentation.SourceCatalog.release_cache` method frees that memory after the desired properties have been calculated. Faster Segmentation Images ========================== Creating a `~photutils.segmentation.SegmentationImage` and getting its ``labels``, ``areas``, and ``slices`` attributes is now typically ~3x to ~15x faster, depending on the number of labels and the fraction of labeled pixels. They are measured by compiled code in a single pass over the segmentation array, producing identical results. Mapping Labels Between Segmentation Images ========================================== The new :meth:`~photutils.segmentation.SegmentationImage.get_label_mapping` method finds the mapping of labels between two segmentation images defined on the same pixel grid. For each label, it returns the labels in the other segmentation image that cover any of the same pixels. A typical use is mapping the original (parent) labels to the deblended (child) labels for segmentation images made before and after deblending, e.g., images read from files. Source Deblending Performance Improvements ========================================== Source deblending with :func:`~photutils.segmentation.deblend_sources` and `~photutils.segmentation.SourceFinder` is now typically 7 to 30 times faster, producing identical results. The whole watershed step now runs in compiled code. The compiled watershed kernel produces results identical to ``skimage.segmentation.watershed``, so scikit-image is no longer an optional dependency. A new ``n_threads`` keyword in :func:`~photutils.segmentation.deblend_sources` and `~photutils.segmentation.SourceFinder` deblends the sources using multiple threads. The sources are divided into chunks and processed concurrently, producing results identical to the single-threaded computation. Each chunk of sources is deblended by a few compiled calls that release the GIL, so multithreading also speeds up fields of many small sources. A new ``contrast_method`` keyword selects the flux used by the deblending contrast criterion. The ``'basin'`` method preserves the existing behavior where the contrast fraction is the total flux in the source's watershed basin, and below-contrast basins are removed iteratively. The new ``'saddle'`` method instead measures the flux a source holds above the saddle level where it separates from its neighbors. It measures the significance of the peak itself, independently of how much envelope territory the source would inherit from the watershed, and requires only a single watershed pass, so it is never slower and is substantially faster when many below-contrast basins would otherwise be removed iteratively. The default of `None` currently resolves to ``'basin'``. The default may change to ``'saddle'`` in version 4.0. Photutils Deprecation Warning Class =================================== All Photutils deprecation warnings are now emitted as `~photutils.utils.PhotutilsDeprecationWarning`, a subclass of astropy's `~astropy.utils.exceptions.AstropyDeprecationWarning`. The warning class name now identifies Photutils as the source of the warning, which makes it easier to find the deprecated code path when a warning is raised from within a larger pipeline. Because it is a subclass, existing warning filters and ``pytest.warns`` checks for ``AstropyDeprecationWarning`` continue to work unchanged. More Robust ePSF Building ========================= `~photutils.psf.EPSFBuilder` no longer produces a checkerboard pattern in the ePSF when the input stars are heterogeneous. This includes stars from exposures with different PSFs, saturated or contaminated cutouts, spurious detections, and low signal-to-noise stars. The issue occurred when the oversampling factor was greater than one. Previously, each star-pixel residual was assigned to the nearest oversampled grid point. As a result, neighboring grid points were estimated from different subsets of stars. These subsets were selected based on the stars' subpixel phases. Noise in the grid biased the fitted star centers toward particular subpixel phases. This prevented alternate grid points from being sampled evenly. The effect grew with each iteration and produced a checkerboard, or alias, pattern. Now, each star-pixel residual is deposited on the oversampled grid points within 0.375 pixel (and at least one grid spacing) of the pixel center along each axis. This ensures that every star contributes to both parities of the grid, regardless of its subpixel phase. In each iteration, power near and above one cycle per input pixel is also removed from the ePSF with a low-pass filter (unit gain up to 0.8 cycles per input pixel, or 0.7 for an oversampling factor of 2). Here a spatial frequency of one cycle per pixel describes structure that repeats with a period of one input pixel, and a frequency of 0.5 cycles per pixel describes structure that repeats every two pixels. A pixel-integrated PSF has essentially no signal at one cycle per input pixel. However, that is the frequency at which the star-pixel sampling lattice aliases onto the oversampled grid. The new ``alias_passband`` keyword sets the passband of this filter. The ePSF of a strongly undersampled detector (e.g., HST WFC3/IR F110W, JWST NIRCam F070W, or Roman WFI F062) has real signal just below one cycle per pixel, and a value of 0.85 or 0.9 with more iterations recovers the peak of such an ePSF more accurately than the default. See the ePSF building user guide for how to choose the value. After the building iterations, the ePSF is now refined (``refinement_iters``, 5 by default) for an oversampling factor of 4 or larger. Each refinement iteration updates the ePSF with the star centers and fluxes held fixed and then refits the stars. These updates use a low-pass filter that does not remove signal near one cycle per pixel, so they restore the real signal of an undersampled ePSF that the filter of the building iterations removes. Without the refinement, that missing signal shows as a ripple pattern along the row and the column through the center of the ePSF. The convergence attributes of `~photutils.psf.EPSFBuildResults` then describe the last refinement iteration, so that they match the returned stars. The `~photutils.psf.EPSFBuildResults` now keep the ePSF image of every iteration (``iteration_epsfs``) and a table of the convergence statistics of each iteration (``iteration_info``), as well as the image of the input ePSF, if any (``initial_epsf``). The new ``plot_iterations`` method plots the ePSF after each iteration and its change from the previous iteration, which shows whether the build has stopped changing and where it is still changing. The new ``center_asymmetry`` attribute of the results is the offset of the center of mass of the built ePSF from its symmetry center (see `~photutils.centroids.centroid_symmetry`). The center of an asymmetric ePSF is a convention, and this offset is the amount by which the star positions measured with the ePSF depend on that convention. The wings of the final ePSF are now smoothed more strongly than its core (``wing_smoothing``, `True` by default). Beyond 3.5 FWHM from the center, where the ePSF is faint and varies slowly, each value is replaced by a least-squares quadratic fit to the values in a box 1.25 to 1.75 FWHM wide. This lowers the noise of the wings, by up to about 50% for undersampled ePSFs built from a few hundred stars. The core changes only by the renormalization of the smoothed ePSF (less than 0.03% in tests). The smoothing also removes real structure in the wings that is finer than about two FWHM, such as diffraction rings and spikes, so set ``wing_smoothing=False`` for large star samples of high signal-to-noise. With an oversampling factor of 1 it can make the wings of a strongly undersampled ePSF less accurate (see the ePSF building user guide). On clean data, the ePSF is at least as accurate as before and is often better. Star centers are recovered more accurately, and the build converges in fewer iterations. A warning is now emitted if the fitted star centers have strongly nonuniform subpixel phases at the end of the build. This indicates that the star centers may be biased. The ePSF building user guide now includes guidelines for choosing the oversampling factor and the star sample, e.g., using at least about four grid points per FWHM, not using an oversampling factor larger than the data require, and providing enough stars to sample every subpixel cell. The fluxes of linked stars (the same star observed in multiple dithered images) are now constrained to their mean value after each fitting iteration, in addition to their centers. This breaks the degeneracy between the flux of a star and its subpixel position caused by intra-pixel sensitivity variations, which would otherwise be absorbed into the ePSF. Set ``constrain_fluxes=False`` if the linked images have different flux scales. The constraint is also available as the new :meth:`~photutils.psf.LinkedEPSFStar.constrain_fluxes` method. The default smoothing kernel and fitting box now scale with the FWHM of the ePSF (``smoothing_kernel='auto'`` and ``fit_shape='auto'``). The previous fixed 5x5 ``'quartic'`` kernel (in oversampled grid points) and 5-pixel fitting box (in detector pixels) were designed for HST images with an oversampling factor of 4, where they correspond to about 0.7 and 2.5 FWHM, respectively. A fixed kernel oversmooths heavily undersampled ePSFs, and a fixed 5-pixel fitting box applied to a well-sampled star uses only its flat core, which biases the fitted centers and can prevent the build from converging. The automatic kernel is a least-squares quartic polynomial kernel at most 0.7 FWHM wide in oversampled grid points (no smoothing below 5 grid points, i.e., for fewer than about 7 grid points per FWHM), and the automatic fitting box is 2 FWHM wide in detector pixels (at least 5 pixels and at most the star cutout size). The choices made in the final iteration are reported in the new ``smoothing_kernel`` and ``fit_shape`` attributes of `~photutils.psf.EPSFBuildResults`, and they can be input as fixed values to reproduce the build. The previous behavior is available with ``smoothing_kernel='quartic'`` and ``fit_shape=5``. ePSF building is also faster. The build now converges when at least a ``converged_fraction`` (default 0.95) of the successfully fitted stars have settled to the requested ``center_accuracy``, instead of requiring every star. A few spurious detections or contaminated cutouts whose centers never settle previously forced the build to run to ``maxiters``. On a real sample of about 2000 stars with 30% spurious detections, the build now stops after 6 iterations instead of 100, in 21 seconds instead of about 5 minutes. The fraction achieved is reported in the new ``final_converged_fraction`` attribute of the results, and ``converged_fraction=1.0`` restores the previous behavior. Each iteration is also about 20% faster (25% at an oversampling of 4) because the ePSF spline interpolators are built once per iteration instead of once per star and the star residuals are stacked in a single vectorized operation. Extracting Stars from Plain Arrays ================================== The `~photutils.psf.extract_stars` function now accepts a plain 2D array, or a list of 2D arrays, as the input ``data``. The image no longer needs to be wrapped in an `~astropy.nddata.NDData` object:: >>> from photutils.psf import extract_stars >>> stars = extract_stars(data, catalog, size=25) # doctest: +SKIP The stars extracted from a plain array are given uniform weights, and the star positions must be input in pixel coordinates. The masked pixels of a `~numpy.ma.MaskedArray` are given zero weight. An `~astropy.nddata.NDData` object is still needed to input an uncertainty, a mask, or a WCS along with the image. Symmetry Centroid ================= The new `~photutils.centroids.centroid_symmetry` function calculates the center of a source as the point about which its core is most symmetric under a rotation by 180 degrees. This is the definition of the center of an ePSF of Anderson 2016 (WFC3 ISR 2016-12). Unlike the center of mass, it is insensitive to asymmetric structure around the core. It is now the default ``recentering_func`` of `~photutils.psf.EPSFBuilder`, which previously centered the ePSF on its center of mass in a 5x5 pixel box. The center of an asymmetric PSF is a convention, and the star positions that are fit with an ePSF follow the convention of that ePSF. The center of mass is pulled toward asymmetric structure around the core, such as an uneven first diffraction ring. In tests with simulated JWST and Roman PSFs, the positions fit with a center-of-mass ePSF were offset from the position of the source in the optical model by up to 0.1 pixel, and by 0.05 pixel or less with a symmetry-centered ePSF. The offset is the same for every star, so it does not affect relative positions measured with one ePSF, but it differs from filter to filter. The accuracy of the built ePSF and of the fitted fluxes is unchanged. Positions fit with a newly built ePSF therefore differ from those of earlier versions by a constant offset for an asymmetric PSF. Use ``recentering_func=centroid_com`` to restore the previous centering. The convergence of a build is now measured from the changes of the star centers relative to their median change. A change that is common to all the stars is a shift of the center of the ePSF, which changes neither its shape nor the relative positions of the stars. The median change must itself be less than ``center_accuracy``, and it is reported in the new ``common_center_shift`` column of ``iteration_info``. With this criterion the ``converged`` flag depends much less on the recentering function. Circular Apertures as Ellipses ============================== A circle on the sky maps to an ellipse in pixel coordinates whenever the pixels are not square or the WCS is sheared, and likewise a pixel circle maps to an ellipse on the sky. The ``to_pixel`` and ``to_sky`` methods of `~photutils.aperture.CircularAperture`, `~photutils.aperture.CircularAnnulus`, `~photutils.aperture.SkyCircularAperture`, and `~photutils.aperture.SkyCircularAnnulus` now accept an ``as_ellipse`` keyword. If `True`, the image of the circle under the local linear WCS at the reference position is returned as an ellipse instead of a circle scaled by the mean pixel scale. The default remains a circle, so existing results are unchanged:: >>> import astropy.units as u >>> from astropy.coordinates import SkyCoord >>> from astropy.wcs import WCS >>> from photutils.aperture import SkyCircularAperture >>> wcs = WCS(naxis=2) >>> wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] >>> wcs.wcs.crval = [150.0, 20.0] >>> wcs.wcs.crpix = [50.0, 50.0] >>> wcs.wcs.cdelt = [-0.05 / 3600, 0.03 / 3600] # nonsquare pixels >>> aperture = SkyCircularAperture(SkyCoord(150.0, 20.0, unit='deg'), ... r=1 * u.arcsec) >>> ellipse = aperture.to_pixel(wcs, as_ellipse=True) >>> ellipse For most imagers the pixel anisotropy is a percent or less, and the flux difference between the ellipse and the mean-scale circle is negligible. The ellipse matters for strongly rectangular pixels or a strongly sheared WCS, and whenever the footprint of the aperture must follow the WCS rather than the mean pixel scale. Faster Aperture WCS Conversions =============================== The ``to_sky`` and ``to_pixel`` methods of all apertures are now two to five times faster. The ``to_pixel`` methods of the sky apertures invert the WCS only once, for the aperture positions, and reuse that pixel position for the shape conversion. Per-Pixel Sky Areas from a WCS ============================== The new :func:`~photutils.utils.compute_pixel_areas` and :func:`~photutils.utils.compute_pixel_area_map` functions compute the on-sky area of image pixels directly from a WCS, either at an array of positions or for every pixel in an image. The area of each pixel is the absolute determinant of the local WCS Jacobian, evaluated with central finite differences, so it follows the distortion across the field and is well defined at the celestial poles and across the RA wraparound. Both functions accept any WCS that supports the astropy shared WCS interface, including ``gwcs`` objects. The pixel area of a distorted image typically varies by a few percent across the field. Converting a surface-brightness image (e.g., in MJy/sr) to fluxes with a single nominal pixel area therefore leaves a position-dependent error. Multiplying by the pixel-area map removes it:: >>> import astropy.units as u >>> from photutils.datasets import make_wcs >>> from photutils.utils import compute_pixel_area_map >>> shape = (100, 100) >>> wcs = make_wcs(shape) >>> areas = compute_pixel_area_map(wcs, shape) * u.arcsec**2 >>> areas[50, 50] >>> pixel_area_sr = areas.to(u.sr) # multiply a MJy/sr image by this The map is interpolated from a coarse grid of positions by default, which is accurate to better than a part per million for typical distortions. Pass ``interpolate=False`` to evaluate the area exactly at every pixel instead. .. _whatsnew-3.1-api-changes: API Changes =========== .. _whatsnew-3.1-deprecations: New Deprecations ---------------- ``PixelAperture.do_photometry`` Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``PixelAperture.do_photometry`` method is now deprecated and will be removed in version 4.0. Use the new `~photutils.aperture.AperturePhotometry` class instead. Deblending ``progress_bar`` and ``n_processes`` Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``progress_bar`` and ``n_processes`` keywords of :func:`~photutils.segmentation.deblend_sources` and `~photutils.segmentation.SourceFinder` are deprecated and will be removed in version 4.0, and both keywords no longer have any effect. Deblending is now dominated by compiled code, so no progress bar is displayed, and the multiprocessing implementation has been removed because its process startup and data-pickling overheads made it slower than the serial implementation. Use the new ``n_threads`` keyword for multithreaded deblending instead. ``inertia_tensor`` Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``inertia_tensor`` property of `~photutils.segmentation.SourceCatalog` and `~photutils.aperture.ApertureStats` is deprecated and will be removed in version 4.0. The inertia tensor carries no information beyond the ``covariance`` matrix. It has the same eigenvectors, and it is equal to the total flux times the difference between the trace of the covariance matrix times the identity matrix and the covariance matrix, with its rows and columns in the reverse ``(y, x)`` order. Use the ``covariance`` property and the shape properties that are derived from it instead. ``make_psf_model`` Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The :func:`~photutils.psf.make_psf_model` function is deprecated and will be removed in version 4.0. The PSF models provided by `photutils.psf` now cover the Gaussian, Moffat, and Airy disk profiles that it was mostly used to wrap, and they are faster to fit. The models that it returns are also not integrated over the detector pixels, and their flux is not the total flux if a shape parameter is fit. For other PSF profiles, make an `~photutils.psf.ImagePSF` from the output of :func:`~photutils.psf.make_epsf_from_psf`, or define a custom `~astropy.modeling.Fittable2DModel` with ``x_0``, ``y_0``, and ``flux`` parameters (see :ref:`psf-custom-models`). ``EPSFBuildResult`` Renamed to ``EPSFBuildResults`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``EPSFBuildResult`` class returned by `~photutils.psf.EPSFBuilder` has been renamed to `~photutils.psf.EPSFBuildResults`. The old ``EPSFBuildResult`` name is deprecated and will be removed in version 4.0. ``ShepardIDWInterpolator.ncoords`` Renamed ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `~photutils.utils.ShepardIDWInterpolator` ``ncoords`` attribute has been renamed to ``n_coords`` for naming consistency within photutils. The old ``ncoords`` name is deprecated and will be removed in version 4.0. ``ApertureStats.ids`` Renamed ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `~photutils.aperture.ApertureStats` ``ids`` attribute has been renamed to ``id``. The old ``ids`` name is deprecated and will be removed in version 4.0. ``ApertureStats.n_apertures`` Renamed ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `~photutils.aperture.ApertureStats` ``n_apertures`` attribute has been renamed to ``n_positions``, matching the `~photutils.aperture.AperturePhotometry` attribute of the same name (both count the aperture positions). The old ``n_apertures`` name is deprecated and will be removed in version 4.0. ``SourceCatalog.progress_bar`` Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `~photutils.segmentation.SourceCatalog` ``progress_bar`` keyword is deprecated and will be removed in version 4.0. All property calculations are now computed for all sources at once in compiled code, so no progress bar is displayed and the keyword has no effect. ``DEFAULT_COLUMNS`` Constants Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``DEFAULT_COLUMNS`` constants in the ``photutils.aperture.stats`` and ``photutils.segmentation.catalog`` modules were removed in version 3.1.0 without a deprecation period. Version 3.1.1 restores them as deprecated names that will be removed in version 4.0. They contain the 3.0 default columns, which do not include the ``flags`` column. Use the ``default_columns`` attribute of `~photutils.aperture.ApertureStats` or `~photutils.segmentation.SourceCatalog` instead. PSF Flags Renamed ^^^^^^^^^^^^^^^^^ Several flags of `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` have been renamed so that each name states what it describes: * ``outside_bounds`` (bit 2) is now ``position_outside_image``. The new name states what is outside the image and avoids confusion with ``parameter_near_bound``. * ``negative_flux`` (bit 4) is now ``non_positive_flux``, because the flag is also set for a zero fitted flux. * ``near_bound`` (bit 32) is now ``parameter_near_bound``, stating that a fitted parameter is near its bound. * ``non_finite_localbkg`` (bit 2048) is now ``non_finite_local_bkg``, matching the ``local_bkg`` column name. The flag constants have been renamed in the same way (e.g., ``OUTSIDE_BOUNDS`` is now ``POSITION_OUTSIDE_IMAGE``). The old names are deprecated and will be removed in version 4.0. The bit values are unchanged, but :func:`~photutils.psf.decode_psf_flags` now returns the new names, so code that compares decoded names with the old names must be updated. ``EPSFBuilder.coord_transformer`` Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `~photutils.psf.EPSFBuilder` ``coord_transformer`` attribute is deprecated and will be removed in a future version. It is an internal helper object that was never intended to be public. Custom ``ImagePSF`` Interpolators Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Defining a custom interpolator for `~photutils.psf.ImagePSF`, by overriding its ``interpolator`` attribute in a subclass or by assigning an interpolator to it, is deprecated and will be removed in version 4.0. Dataset Loading Functions Deprecated ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The :func:`~photutils.datasets.load_irac_psf` and :func:`~photutils.datasets.load_simulated_hst_star_image` functions are deprecated and will be removed in version 4.0. The Spitzer/IRAC PSF images and the simulated HST star image remain available in the `photutils-datasets `_ repository and can be loaded directly with :func:`astropy.io.fits.getdata`, as shown in the :ref:`PSF matching ` and :ref:`ePSF building ` user guides. .. _whatsnew-3.1-breaking-changes: Breaking Changes ---------------- Deblending Fallback for Nonpositive Minima Changed to sinh ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When deblending with ``mode='exponential'`` (the default), sources with nonpositive minimum data values cannot use the exponential threshold spacing and fall back to another mode. The fallback mode has changed from "linear" to "sinh". Like the linear spacing, the sinh spacing is well defined for any data values, but it keeps the threshold levels concentrated near the source minimum, recovering faint companions of bright sources that the linear spacing misses. Deblending results can therefore change for the affected sources, which continue to be recorded under the ``'non_positive_min_labels'`` key of the ``info`` attribute and flagged with ``deblend_non_positive_min``. To reproduce the previous spacing for such sources, pass ``mode='linear'``. Note that this applies the linear spacing to every source, which is the only way to obtain the previous behavior. Covariance Regularization of Unresolved Sources ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``covariance`` matrix of :class:`~photutils.segmentation.SourceCatalog` and :class:`~photutils.aperture.ApertureStats` is regularized for unresolved sources, because a source cannot have a second moment smaller than ``1/12`` pixel**2, the variance of a uniform distribution across a single pixel. Previously, ``1/12`` was added to both diagonal elements when the covariance determinant was below ``(1/12)**2``. That approach missed a thin source that is unresolved along only its minor axis if its major axis is long, inflated the variance along a resolved axis, and was discontinuous. The variance along each principal axis (each eigenvalue of the covariance matrix) is now raised to at least ``1/12`` while the orientation is kept. A resolved axis is not modified, and the result is a continuous function of the image moments. The sources that are regularized are exactly those with the ``singular_covariance`` flag. Sources whose second-order moments are not positive semidefinite have NaN shape properties and the ``undefined_shape`` flag instead. A source that is unresolved along both axes becomes isotropic with an orientation of zero. An exactly thin tilted source, whose covariance determinant is zero apart from rounding, is now regularized instead of having NaN shape properties. Exact Pixel Integration of a Rotated ``GaussianPRF`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `~photutils.psf.GaussianPRF` is now integrated exactly over the detector pixels for any rotation angle. Previously, the integration was performed along the rotated principal axes of the Gaussian, which is the integral over a rotated pixel and was exact only for ``theta = 0``. The model values for a rotated Gaussian with different x and y widths change by up to about 1% of the peak for a FWHM of 1 pixel and by less than 0.1% for a FWHM of 2 pixels or more. The model now conserves flux for any subpixel position of the source. A rotated Gaussian is evaluated with Owen's T function, which is about four times slower than the product of error functions that is still used when the Gaussian is not rotated (roughly 0.2 ms instead of 0.05 ms for a 25x25 pixel stamp). The slower evaluation applies only when ``theta`` is not a multiple of 90 degrees and the x and y widths differ. Positions that are not on a grid with a spacing of one pixel are about ten times slower, because their pixels share no corners. Pixel Scale of Circular Aperture Conversions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``to_pixel`` and ``to_sky`` methods of the circular apertures and annuli now use the geometric mean of the two singular values of the local WCS Jacobian as the isotropic pixel scale for every WCS. This scale preserves the area of the mapped circle and inverts exactly, so a conversion to pixels and back returns the original radius. Previously, a distorted WCS or a ``gwcs`` used the arithmetic mean of the singular values. The radii are unchanged for an undistorted WCS with orthogonal pixel axes. Aperture Photometry Masks Must Be Boolean ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``mask`` input to aperture photometry and to the aperture ``area_overlap`` method must now be a boolean array (or `None`). Orientation Range ^^^^^^^^^^^^^^^^^ The ``orientation`` values returned by `~photutils.segmentation.SourceCatalog`, `~photutils.detection.DAOStarFinder`, and `~photutils.detection.IRAFStarFinder` are now in the range (-90, 90] degrees, consistent with `~photutils.aperture.ApertureStats`. Previously, they were incorrectly wrapped to [0, 360) degrees. Windowed Centroid Fallback Conditions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The conditions for the `~photutils.segmentation.SourceCatalog` ``centroid_win`` fallback to the isophotal centroid have been expanded. The windowed centroid is now also reset when the total weighted flux is nonpositive, the windowed second-order moments are negative, or the windowed covariance determinant is negative. Previously, it was reset only when it had diverged far from the isophotal centroid and was outside the 1-sigma ellipse. Segmentation Input Validation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :func:`~photutils.segmentation.detect_sources` now emits an ``AstropyUserWarning`` if the ``threshold`` is negative or, for a threshold array, has any negative values. `~photutils.segmentation.SegmentationImage` now raises a ``TypeError`` for masked array input. PSF Photometry ``mask`` and ``error`` with ``NDData`` Input ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``mask`` and ``error`` keywords of `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` now raise a ``ValueError`` when the input data is an `~astropy.nddata.NDData` instance. Previously, they were silently ignored in favor of the `~astropy.nddata.NDData` attributes. ``resize_psf`` Output Size ^^^^^^^^^^^^^^^^^^^^^^^^^^ :func:`~photutils.psf_matching.resize_psf` now returns a PSF with exactly the requested pixel scale. Previously, the output pixel scale was off by a factor of ``(n_in - 1) / (n_out - 1)``, where ``n_in`` and ``n_out`` are the input and output sizes, which made the resized PSF too broad or too narrow by up to several percent. The output size is now the largest odd size for which the output grid lies within the outermost pixel centers of the input PSF, which can be smaller than before. ``calc_total_error`` Output Dtype ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :func:`~photutils.utils.calc_total_error` now returns an array whose dtype follows the NumPy type promotion of its floating-point inputs, with a minimum of ``float32``. For example, ``float32`` inputs now give a ``float32`` total error. Previously, the result was always ``float64``. Integer inputs still give a ``float64`` result. PSF ``decode_flags`` Methods Return a Dictionary ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The :meth:`photutils.psf.PSFPhotometry.decode_flags` and :meth:`photutils.psf.IterativePSFPhotometry.decode_flags` methods now return a dictionary mapping each source ID from the results table to its list of active flag names (or bit values), instead of a positional list of lists. Decoded flags can now be looked up directly by source ID, which may not be sequential when custom IDs are input via ``init_params``, and the result is consistent with the new aperture and segmentation ``decode_flags`` methods. Code that iterated over the previous list should iterate over the dictionary's ``.values()`` (or ``.items()`` to also get the source IDs). The module-level :func:`~photutils.psf.decode_psf_flags` function is unchanged and still returns lists. WebbPSF ePSF Grid Positions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Reading a WebbPSF gridded-ePSF FITS file with :func:`~photutils.psf.webbpsf_reader` (or ``GriddedPSFModel.read(..., format='webbpsf')``) now assigns the reference ePSFs to the correct detector positions. The ``DET_YX{i}`` header keywords record each position as ``(y, x)``, but the values were previously stored as ``(x, y)``, which transposed the ePSF grid. Grids whose x and y fiducial positions are identical (such as the square NIRCam short-wavelength grids) are unaffected. ``StarFinderCatalogBase`` Representation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``repr`` and ``str`` output of `~photutils.detection.StarFinderCatalogBase` now labels the number of sources as ``n_sources`` instead of ``nsources`` for naming consistency within photutils. ``STDPSFGrid`` Metadata ^^^^^^^^^^^^^^^^^^^^^^^ The ``grid_xypos``, ``oversampling``, and ``grid_shape`` keys have been removed from the `~photutils.psf.STDPSFGrid` ``meta`` dictionary. The ``meta`` dictionary now contains only the metadata parsed from the filename (e.g., ``STDPSF``, ``detector``, and ``filter``). The grid positions, oversampling factors, and grid shape are available as the ``grid_xypos`` and ``oversampling`` attributes and the new ``grid_shape`` property, which are now the single source of truth for this information:: >>> from photutils.psf import STDPSFGrid >>> psfgrid = STDPSFGrid('STDPSF_ACSWFC_F814W.fits') >>> psfgrid.grid_xypos # instead of psfgrid.meta['grid_xypos'] >>> psfgrid.oversampling # instead of psfgrid.meta['oversampling'] The ``grid_xypos`` attribute is now a `~numpy.ndarray` instead of a list, matching the `~photutils.psf.GriddedPSFModel` attribute of the same name, and ``oversampling`` is now a read-only property. Relatedly, the `~photutils.psf.GriddedPSFModel` ``meta`` dictionary now always stores ``'grid_xypos'`` as the sorted array of grid positions and ``'oversampling'`` as a normalized ``(y, x)`` tuple, so that both always match the attributes of the same name. ``GriddedPSFModel`` ``'grid_shape'`` Metadata Key ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``'grid_shape'`` key has been removed from the `~photutils.psf.GriddedPSFModel` ``meta`` dictionary. Use the new ``grid_shape`` attribute instead, i.e., ``psfmodel.grid_shape`` instead of ``psfmodel.meta['grid_shape']``. The redundant ``nxpsfs`` and ``nypsfs`` metadata keys of a `~photutils.psf.GriddedPSFModel` read from a STDPSF file have also been removed. Deblending Warning Information ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :func:`~photutils.segmentation.deblend_sources` now emits a `~photutils.utils.DeblendWarning` (a subclass of astropy's ``AstropyUserWarning``) when the deblending mode is changed to a fallback mode for one or more sources. The affected input labels are now stored as arrays directly under ``'non_positive_min_labels'`` and ``'too_many_markers_labels'`` keys in the returned `~photutils.segmentation.SegmentationImage` ``info`` dictionary, replacing the nested ``info['warnings']`` dictionary. ``find_peaks`` ``min_separation`` Enforcement ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The ``min_separation`` keyword of :func:`~photutils.detection.find_peaks` now strictly enforces the minimum separation between all returned peaks. Previously, the result was equivalent to passing a circular ``footprint`` of the given radius to :func:`~scipy.ndimage.maximum_filter`, which returns every pixel that is tied for the maximum. Pixels with identical values could therefore all be returned as peaks, even when they were adjacent (e.g., the pixels of a saturated star or peaks in integer-valued data). Pixels with identical values are now handled in two steps. First, each group of connected peak pixels is replaced by its pixel nearest to the centroid of the group. A saturated star therefore gives one peak near its center, even if its saturated core is larger than ``min_separation``. Second, for any remaining equal-valued peaks that are separated by ``min_separation`` or less, only one is kept. The peak of the largest group of connected pixels is preferred, so that a saturated star is kept instead of a nearby isolated saturated pixel. The returned peaks are a subset of the peaks previously found. Use a circular ``footprint`` to return every pixel that is tied for the maximum, as before. The star finder classes (:class:`~photutils.detection.DAOStarFinder`, :class:`~photutils.detection.IRAFStarFinder`, and :class:`~photutils.detection.StarFinder`), which use ``min_separation`` internally, may now return fewer sources if the convolved image has pixels with identical values. Such pixels are uncommon for floating-point data. Note that the star finders apply ``min_separation`` to the integer peak-pixel positions. The subpixel centroids computed afterwards may shift sources slightly, so the centroid-based separations in the output tables can be slightly smaller than ``min_separation``. ``IRAFStarFinder`` Minimum Number of Pixels ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :class:`~photutils.detection.IRAFStarFinder` now rejects sources with fewer than 5 positive sky-subtracted pixels within the kernel footprint by default. This matches the default (and minimum) value of the ``npixmin`` parameter in IRAF's starfind. Previously, only 2 such pixels were required, so fewer faint sources may now be returned. The threshold is set by the new ``min_n_pixels`` keyword. Set ``min_n_pixels=2`` to recover the previous behavior. astropy-photutils-24be529/photutils/000077500000000000000000000000001526452531300176435ustar00rootroot00000000000000astropy-photutils-24be529/photutils/__init__.py000066400000000000000000000022041526452531300217520ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Photutils is an Astropy coordinated package that provides tools for detecting and performing photometry of astronomical sources. It also has tools for background estimation, image segmentation, ePSF building, PSF matching, radial profiles, centroiding, elliptical isophote fitting, and morphological measurements. """ try: from .version import version as __version__ except ImportError: __version__ = '' from ._future_column_names 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. Use this with caution, as it is a global flag that affects all photutils functions across all packages that return tables. A better approach is to use ``photutils.use_future_column_names`` as a context manager. This provides a scoped override without modifying the global flag:: with photutils.use_future_column_names(): table = cat.to_table() # returns a plain QTable """ astropy-photutils-24be529/photutils/_future_column_names.py000066400000000000000000000037251526452531300244350ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for opting in to the future table column names. This module is imported by the top-level package, so it must not import anything from ``photutils.utils`` or any other heavy dependency. """ from contextlib import contextmanager from contextvars import ContextVar _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`` were set to ``enabled``, without modifying the global flag. This is safe to use in multithreaded 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) astropy-photutils-24be529/photutils/aperture/000077500000000000000000000000001526452531300214725ustar00rootroot00000000000000astropy-photutils-24be529/photutils/aperture/__init__.py000066400000000000000000000011531526452531300236030ustar00rootroot00000000000000# 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 .region_converters import * # noqa: F401, F403 from .core import * # noqa: F401, F403 from .ellipse import * # noqa: F401, F403 from .flags import * # noqa: F401, F403 from .mask import * # noqa: F401, F403 from .photometry import * # noqa: F401, F403 from .polygon import * # noqa: F401, F403 from .rectangle import * # noqa: F401, F403 from .stats import * # noqa: F401, F403 astropy-photutils-24be529/photutils/aperture/_batch_outside.pxd000066400000000000000000000031261526452531300251650ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Declarations of the outside-weight scan of the batch aperture photometry driver (see ``_batch_outside.pyx``). """ ctypedef struct _ShapeSpec: # The aperture shape parameters of one batch_aperture_sums call. # The pointer members reference scratch buffers local to that call. int shape_code int use_exact int subpixels double r_in double r_out double rx_in double ry_in double rx_out double ry_out double cos_theta double sin_theta double half_width_in double half_height_in double half_width_out double half_height_out double bbox_dx_in double bbox_dy_in double bbox_dx_out double bbox_dy_out double *poly_x_in double *poly_y_in double *poly_x_out double *poly_y_out double *buf_a_x double *buf_a_y double *buf_b_x double *buf_b_y double *poly_x double *poly_y int n_poly int poly_buf_size int is_poly_convex double *pedge_nx double *pedge_ny double *pedge_c double *pbuf_a_x double *pbuf_a_y double *pbuf_b_x double *pbuf_b_y cdef bint outside_weight(const _ShapeSpec *sp, bint inside_any, double gxmin, double gymin, double dx, double dy, double pixel_radius, double norm, Py_ssize_t ixmin, Py_ssize_t iymin, Py_ssize_t ixmax_full, Py_ssize_t iymax_full, Py_ssize_t ix0, Py_ssize_t ix1, Py_ssize_t iy0, Py_ssize_t iy1) noexcept nogil astropy-photutils-24be529/photutils/aperture/_batch_outside.pyx000066400000000000000000000256061526452531300252210ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ The outside-weight scan of the batch aperture photometry driver. ``batch_aperture_sums`` reports, for each aperture whose bounding box is clipped by a data edge, whether the aperture has a nonzero-fraction pixel outside the data. The scan lives in its own extension module so that the per-shape pixel-overlap helpers of ``_batch_overlap.pxd`` keep a single call site in the driver's translation unit. With more call sites the C compiler stops inlining the larger helpers into the driver's per-pixel loop, which measured up to 30% slower. The scan runs without the GIL and uses no global mutable state, so it is safe to call from multiple threads, including on free-threaded Python builds. """ from photutils.aperture._batch_overlap cimport ( _CIRCLE, _CIRCULAR_ANNULUS, _ELLIPSE, _ELLIPTICAL_ANNULUS, _RECTANGLE, _RECTANGULAR_ANNULUS, _circle_pixel_frac, _circular_annulus_pixel_frac, _ellipse_pixel_frac, _elliptical_annulus_pixel_frac, _polygon_pixel_frac, _rect_pixel_frac, _rectangular_annulus_pixel_frac) __all__ = [] cdef inline double _shape_pixel_frac(const _ShapeSpec *sp, double pxmin, double pymin, double dx, double dy, double pixel_radius, double norm) noexcept nogil: """ Return the overlap fraction of one pixel with the aperture shape. This is the same dispatch on the shape code that the accumulation loop of ``batch_aperture_sums`` carries inline over its local variables. Keep the two in step. """ if sp.shape_code == _CIRCLE: return _circle_pixel_frac(pxmin, pymin, dx, dy, pixel_radius, sp.r_out, sp.use_exact, sp.subpixels) if sp.shape_code == _CIRCULAR_ANNULUS: return _circular_annulus_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, sp.r_in, sp.r_out, sp.use_exact, sp.subpixels) if sp.shape_code == _ELLIPSE: return _ellipse_pixel_frac( pxmin, pymin, dx, dy, norm, sp.rx_out, sp.ry_out, sp.cos_theta, sp.sin_theta, sp.use_exact, sp.subpixels) if sp.shape_code == _ELLIPTICAL_ANNULUS: return _elliptical_annulus_pixel_frac( pxmin, pymin, dx, dy, norm, sp.rx_in, sp.ry_in, sp.rx_out, sp.ry_out, sp.cos_theta, sp.sin_theta, sp.use_exact, sp.subpixels) if sp.shape_code == _RECTANGLE: return _rect_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, sp.half_width_out, sp.half_height_out, sp.cos_theta, sp.sin_theta, sp.bbox_dx_out, sp.bbox_dy_out, sp.poly_x_out, sp.poly_y_out, sp.buf_a_x, sp.buf_a_y, sp.buf_b_x, sp.buf_b_y, sp.use_exact, sp.subpixels) if sp.shape_code == _RECTANGULAR_ANNULUS: return _rectangular_annulus_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, sp.half_width_in, sp.half_height_in, sp.half_width_out, sp.half_height_out, sp.cos_theta, sp.sin_theta, sp.bbox_dx_in, sp.bbox_dy_in, sp.bbox_dx_out, sp.bbox_dy_out, sp.poly_x_in, sp.poly_y_in, sp.poly_x_out, sp.poly_y_out, sp.buf_a_x, sp.buf_a_y, sp.buf_b_x, sp.buf_b_y, sp.use_exact, sp.subpixels) return _polygon_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, sp.poly_x, sp.poly_y, sp.n_poly, sp.pedge_nx, sp.pedge_ny, sp.pedge_c, sp.is_poly_convex, sp.pbuf_a_x, sp.pbuf_a_y, sp.pbuf_b_x, sp.pbuf_b_y, sp.poly_buf_size, sp.use_exact, sp.subpixels) cdef inline bint _ring_row(const _ShapeSpec *sp, Py_ssize_t iy, Py_ssize_t ix_start, Py_ssize_t ix_stop, Py_ssize_t ixc, double gxmin, double gymin, double dx, double dy, double pixel_radius, double norm, Py_ssize_t ixmin, Py_ssize_t iymin) noexcept nogil: """ Whether a pixel of row ``iy`` in ``[ix_start, ix_stop)`` has a nonzero fraction, testing outward from the column ``ixc`` nearest the aperture center (where a crossing of the data edge is found soonest). """ cdef Py_ssize_t lo, hi cdef double pymin = gymin + (iy - iymin) * dy lo = ixc if ixc > ix_start else ix_start lo = lo if lo < ix_stop else ix_stop - 1 hi = lo + 1 while lo >= ix_start or hi < ix_stop: if lo >= ix_start: if _shape_pixel_frac(sp, gxmin + (lo - ixmin) * dx, pymin, dx, dy, pixel_radius, norm) > 0.0: return True lo -= 1 if hi < ix_stop: if _shape_pixel_frac(sp, gxmin + (hi - ixmin) * dx, pymin, dx, dy, pixel_radius, norm) > 0.0: return True hi += 1 return False cdef inline bint _ring_column(const _ShapeSpec *sp, Py_ssize_t ix, Py_ssize_t iy_start, Py_ssize_t iy_stop, Py_ssize_t iyc, double gxmin, double gymin, double dx, double dy, double pixel_radius, double norm, Py_ssize_t ixmin, Py_ssize_t iymin) noexcept nogil: """ The ``_ring_row`` test for the column ``ix`` over the rows ``[iy_start, iy_stop)``, testing outward from the row ``iyc``. """ cdef Py_ssize_t lo, hi cdef double pxmin = gxmin + (ix - ixmin) * dx lo = iyc if iyc > iy_start else iy_start lo = lo if lo < iy_stop else iy_stop - 1 hi = lo + 1 while lo >= iy_start or hi < iy_stop: if lo >= iy_start: if _shape_pixel_frac(sp, pxmin, gymin + (lo - iymin) * dy, dx, dy, pixel_radius, norm) > 0.0: return True lo -= 1 if hi < iy_stop: if _shape_pixel_frac(sp, pxmin, gymin + (hi - iymin) * dy, dx, dy, pixel_radius, norm) > 0.0: return True hi += 1 return False cdef bint outside_weight(const _ShapeSpec *sp, bint inside_any, double gxmin, double gymin, double dx, double dy, double pixel_radius, double norm, Py_ssize_t ixmin, Py_ssize_t iymin, Py_ssize_t ixmax_full, Py_ssize_t iymax_full, Py_ssize_t ix0, Py_ssize_t ix1, Py_ssize_t iy0, Py_ssize_t iy1) noexcept nogil: """ Whether an aperture whose bounding box is clipped by a data edge has a nonzero-fraction pixel outside the data. For the exact overlap method, when the aperture has a nonzero-fraction pixel inside the data, only the one-pixel ring just outside the data edges (within the unclipped bounding box) is tested. Every aperture shape is a connected region, so one with positive area both inside and outside the data crosses the data boundary at an interior point, and the ring pixel containing that point has a positive exact overlap. This makes the cost of the test scale with the data perimeter rather than with the (possibly enormous) bounding-box area. Otherwise every outside pixel is tested until one has a nonzero fraction. The center and subpixel methods sample the shape at pixel or subpixel centers, for which the ring argument does not hold, and an aperture with no weight inside the data may lie entirely in the clipped-away part of its bounding box (that scan is short, because such a shape reaches the first rows of its own bounding box). Parameters ---------- sp : const _ShapeSpec * The aperture shape parameters. inside_any : bint Whether the aperture has a nonzero-fraction pixel inside the data. gxmin, gymin, dx, dy, pixel_radius, norm : double The pixel grid of the source (see ``_source_grid_setup``). ixmin, iymin, ixmax_full, iymax_full : Py_ssize_t The unclipped bounding box (the maxima are exclusive). ix0, ix1, iy0, iy1 : Py_ssize_t The bounding box clipped to the data (the maxima are exclusive). Returns ------- result : bint `True` if a pixel outside the data has a nonzero overlap fraction. """ cdef Py_ssize_t ix, iy, cx0, cx1, ixc, iyc cdef double pxmin, pymin if sp.use_exact and inside_any: # The columns of the rows just outside the top and bottom data # edges include the corner pixels. Each ring row or column is # tested outward from the pixel nearest the aperture center, # where the shape most likely crosses the data edge. cx0 = ixmin if ixmin > ix0 - 1 else ix0 - 1 cx1 = ixmax_full if ixmax_full < ix1 + 1 else ix1 + 1 ixc = ixmin + (-gxmin / dx) iyc = iymin + (-gymin / dy) if iymin < iy0 and _ring_row(sp, iy0 - 1, cx0, cx1, ixc, gxmin, gymin, dx, dy, pixel_radius, norm, ixmin, iymin): return True if iy1 < iymax_full and _ring_row(sp, iy1, cx0, cx1, ixc, gxmin, gymin, dx, dy, pixel_radius, norm, ixmin, iymin): return True if ixmin < ix0 and _ring_column(sp, ix0 - 1, iy0, iy1, iyc, gxmin, gymin, dx, dy, pixel_radius, norm, ixmin, iymin): return True if ix1 < ixmax_full and _ring_column(sp, ix1, iy0, iy1, iyc, gxmin, gymin, dx, dy, pixel_radius, norm, ixmin, iymin): return True return False # Sampled overlap: test every outside pixel of the unclipped # bounding box, skipping the columns inside the data on the rows # that overlap it for iy in range(iymin, iymax_full): pymin = gymin + (iy - iymin) * dy if iy0 <= iy < iy1: for ix in range(ixmin, ix0): pxmin = gxmin + (ix - ixmin) * dx if _shape_pixel_frac(sp, pxmin, pymin, dx, dy, pixel_radius, norm) > 0.0: return True for ix in range(ix1, ixmax_full): pxmin = gxmin + (ix - ixmin) * dx if _shape_pixel_frac(sp, pxmin, pymin, dx, dy, pixel_radius, norm) > 0.0: return True else: for ix in range(ixmin, ixmax_full): pxmin = gxmin + (ix - ixmin) * dx if _shape_pixel_frac(sp, pxmin, pymin, dx, dy, pixel_radius, norm) > 0.0: return True return False astropy-photutils-24be529/photutils/aperture/_batch_overlap.pxd000066400000000000000000001303231526452531300251610ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True """ Shared per-pixel aperture overlap helpers for the batch Cython drivers for aperture photometry and statistics. Each helper returns the fraction of a single pixel that overlaps an aperture shape centered on the origin, using exactly the same arithmetic as the corresponding `photutils.geometry` grid function (including the bounding-box and interior/exterior fast paths). The helpers are ``cdef inline`` so that they are inlined into the per-pixel loop of each importing module (e.g., ``_batch_photometry`` and ``_batch_stats``), avoiding a function call per pixel. This file also declares the aperture shape codes and the once-per-source helpers shared by the batch drivers (bounding-box/pixel-grid setup and packed-buffer presizing). These functions are pure C math and use no global mutable state, so they are safe to call without the GIL, including on free-threaded Python builds. """ from photutils.geometry._polygon_overlap cimport ( convex_polygon_pixel_overlap, convex_polygon_pixel_subpixel, polygon_pixel_overlap) from photutils.geometry.circle_overlap cimport circle_frac_from_d2 from photutils.geometry.ellipse_overlap cimport ellipse_frac_from_rpix2 from photutils.geometry.rectangle_overlap cimport ( rectangle_overlap_single_subpixel) # The image dtypes read directly by the batch kernels. Pixel values are # converted to double as they are read, which is exact, so the float and # double specializations perform the same double-precision arithmetic. # Their results agree to within rounding (the C compiler may fuse # multiply-add operations differently in the two specializations). # Inputs with any other dtype are converted to one of these by the # Python callers (see ``photutils.aperture._common.batch_image_dtype``). ctypedef fused real_t: float double ctypedef fused seg_t: int Py_ssize_t cdef extern from "math.h" nogil: double sqrt(double x) double fabs(double x) double fmax(double x, double y) double fmin(double x, double y) double floor(double x) double ceil(double x) # Aperture shape codes. These must stay in sync with the private # ``_shape_params`` attributes defined by the aperture classes in # ``photutils.aperture`` (see also the Python-level ``SHAPE_*`` aliases # in ``_batch_photometry``). cdef enum: _CIRCLE = 0 _CIRCULAR_ANNULUS = 1 _ELLIPSE = 2 _ELLIPTICAL_ANNULUS = 3 _RECTANGLE = 4 _RECTANGULAR_ANNULUS = 5 _POLYGON = 6 cdef inline Py_ssize_t _round_half_away(double x) noexcept nogil: """ Round to the nearest integer, rounding halfway values away from zero. This replicates ``photutils.utils._round.round_half_away``. Parameters ---------- x : double The value to round. Returns ------- result : Py_ssize_t The rounded integer value. """ if x >= 0.0: return floor(x + 0.5) return ceil(x - 0.5) cdef enum: # Outcomes of the segmentation masking method for a single pixel # (see ``_classify_seg_pixel``) _SEG_SOURCE = 0 _SEG_EXCLUDED = 1 _SEG_NEIGHBOR = 2 _SEG_CORRECTED = 3 _SEG_UNCORRECTED = 4 cdef inline bint _seg_method_active(int seg_method, Py_ssize_t label) noexcept nogil: """ Whether the segmentation masking method applies to a source. A label of 0 disables the masking for that source, except for method 3 ('background_only'), which does not use the label. Parameters ---------- seg_method : int The segmentation masking method code (0 disables masking). label : Py_ssize_t The target source label. Returns ------- active : bint `True` if the per-pixel segmentation helpers must be called for this source. """ return seg_method != 0 and (label != 0 or seg_method == 3) cdef inline int _classify_seg_pixel(const seg_t *segmentation, const unsigned char *mask, Py_ssize_t nx_data, int seg_method, Py_ssize_t label, Py_ssize_t ix, Py_ssize_t iy, Py_ssize_t ix0, Py_ssize_t ix1, Py_ssize_t iy0, Py_ssize_t iy1, Py_ssize_t ccx, Py_ssize_t ccy, Py_ssize_t *six, Py_ssize_t *siy) noexcept nogil: """ Classify a single pixel under the segmentation masking method. This is the segmentation-masking query shared by the per-pixel loops of the aperture and segmentation batch drivers. It has no side effects other than writing the mirror pixel coordinates of a corrected pixel, so a caller counts or uses the outcome as it needs (see the ``_resolve_seg_pixel`` and ``_seg_pixel_contributes`` wrappers). The caller must invoke it only when a segmentation array is present and the method is active for the source (see ``_seg_method_active``). Parameters ---------- segmentation : const seg_t * The C-contiguous segmentation array data. mask : const unsigned char * The C-contiguous mask-plane data, or NULL if there is no mask. Used only by the symmetric 'correct' method (``seg_method`` 4) to reject mirror pixels that are marked in the plane (i.e., input-masked pixels, and nonfinite pixels when the caller marks them in the plane). The aperture drivers called with ``mask_nonfinite`` reject nonfinite mirror pixels themselves. nx_data : Py_ssize_t The row stride (number of columns) of the data arrays. seg_method : int The segmentation masking method code: 1 excludes neighbor-source pixels, 2 excludes all pixels not assigned to the target source, 3 excludes all labeled pixels, and 4 replaces neighbor-source pixels with the values mirrored across the aperture center (see ``batch_aperture_sums``). label : Py_ssize_t The target source label (nonzero, except for method 3, which ignores it). ix, iy : Py_ssize_t The pixel coordinates. ix0, ix1, iy0, iy1 : Py_ssize_t The bounding-box limits, clipped to the image. For method 4, a mirror pixel outside these limits cannot be used. ccx, ccy : Py_ssize_t The rounded aperture center used by the method-4 mirror. six, siy : Py_ssize_t * Output. Overwritten with the mirror pixel coordinates only for a ``_SEG_CORRECTED`` outcome. Returns ------- code : int One of the ``_SEG_*`` codes: * ``_SEG_SOURCE``: not a neighbor-source pixel (or the method is disabled). The pixel contributes its own value * ``_SEG_EXCLUDED``: a background pixel excluded by method 2 (not a neighbor-source pixel) * ``_SEG_NEIGHBOR``: a neighbor-source pixel excluded by method 1, 2, or 3 (for method 3, every labeled pixel) * ``_SEG_CORRECTED``: a neighbor-source pixel replaced by the mirror pixel written to ``(siy[0], six[0])`` (method 4) * ``_SEG_UNCORRECTED``: a neighbor-source pixel whose mirror pixel is unavailable (method 4). The pixel is excluded """ cdef Py_ssize_t seg_val = segmentation[iy * nx_data + ix] cdef Py_ssize_t xm, ym, mseg if seg_method == 1: if seg_val != 0 and seg_val != label: return _SEG_NEIGHBOR elif seg_method == 2: if seg_val != label: # Only neighbor-source pixels (not background pixels) # count toward the neighbor-pixels flag. return _SEG_NEIGHBOR if seg_val != 0 else _SEG_EXCLUDED elif seg_method == 3: if seg_val != 0: return _SEG_NEIGHBOR elif seg_method == 4: if seg_val != 0 and seg_val != label: # Neighbor pixel: replace its value with the pixel # mirrored across the center, if that pixel is available. xm = 2 * ccx - ix ym = 2 * ccy - iy if xm < ix0 or xm >= ix1 or ym < iy0 or ym >= iy1: return _SEG_UNCORRECTED mseg = segmentation[ym * nx_data + xm] if mseg != 0 and mseg != label: return _SEG_UNCORRECTED if mask != NULL and mask[ym * nx_data + xm]: return _SEG_UNCORRECTED six[0] = xm siy[0] = ym return _SEG_CORRECTED return _SEG_SOURCE cdef inline bint _seg_pixel_contributes(const seg_t *segmentation, const unsigned char *mask, Py_ssize_t nx_data, int seg_method, Py_ssize_t label, Py_ssize_t ix, Py_ssize_t iy, Py_ssize_t ix0, Py_ssize_t ix1, Py_ssize_t iy0, Py_ssize_t iy1, Py_ssize_t ccx, Py_ssize_t ccy, Py_ssize_t *six, Py_ssize_t *siy) noexcept nogil: """ Whether a pixel contributes under the segmentation masking method. This is the uncounted form of ``_resolve_seg_pixel`` for callers that keep no neighbor-pixel flag counts. The parameters are those of ``_classify_seg_pixel``. Returns ------- contributes : bint `True` if the pixel contributes to the measurement (reading its value from ``(siy[0], six[0])``) and `False` if it is excluded. """ cdef int code = _classify_seg_pixel(segmentation, mask, nx_data, seg_method, label, ix, iy, ix0, ix1, iy0, iy1, ccx, ccy, six, siy) return code == _SEG_SOURCE or code == _SEG_CORRECTED cdef inline bint _resolve_seg_pixel(const seg_t *segmentation, const unsigned char *mask, Py_ssize_t nx_data, int seg_method, Py_ssize_t label, Py_ssize_t ix, Py_ssize_t iy, Py_ssize_t ix0, Py_ssize_t ix1, Py_ssize_t iy0, Py_ssize_t iy1, Py_ssize_t ccx, Py_ssize_t ccy, Py_ssize_t *six, Py_ssize_t *siy, Py_ssize_t *n_seg_px, Py_ssize_t *n_uncorr) noexcept nogil: """ Apply the segmentation masking method to a single unmasked pixel, counting the neighbor-pixel outcomes. This is the counting form of ``_classify_seg_pixel`` used by the per-pixel loops of ``_batch_photometry.batch_aperture_sums`` and ``_batch_stats.batch_aperture_gather`` for their unmasked pixels. The parameters are those of ``_classify_seg_pixel`` plus the two counters. Parameters ---------- n_seg_px : Py_ssize_t * In/out. Incremented when the pixel is excluded, restricted, or corrected due to a neighboring source. n_uncorr : Py_ssize_t * In/out. Incremented when a method-4 neighbor pixel could not be corrected (the mirror pixel was unavailable). Returns ------- contributes : bint `True` if the pixel contributes to the aperture (reading its value from ``(siy[0], six[0])``) and `False` if it is excluded. """ cdef int code = _classify_seg_pixel(segmentation, mask, nx_data, seg_method, label, ix, iy, ix0, ix1, iy0, iy1, ccx, ccy, six, siy) if code == _SEG_SOURCE: return True if code == _SEG_EXCLUDED: return False n_seg_px[0] += 1 if code == _SEG_CORRECTED: return True if code == _SEG_UNCORRECTED: n_uncorr[0] += 1 return False cdef inline bint _source_grid_setup(double cx, double cy, double ext_x, double ext_y, double off_x, double off_y, Py_ssize_t nx_data, Py_ssize_t ny_data, double *gxmin, double *gymin, double *dx, double *dy, double *pixel_radius, double *norm, Py_ssize_t *ixmin, Py_ssize_t *iymin, Py_ssize_t *ix0, Py_ssize_t *ix1, Py_ssize_t *iy0, Py_ssize_t *iy1) noexcept nogil: """ Compute the pixel grid and bounding box for a single source aperture. This reproduces the geometry computed by ``BoundingBox.from_float``, ``BoundingBox.get_overlap_slices``, and the ``Aperture._centered_edges`` grid setup used by the ``photutils.geometry`` routines. Consequently, the batch drivers iterate over exactly the same pixels with the same grid geometry as the mask-based code path. The bounding-box coordinates are stored as integral ``double`` values rather than integers to avoid overflow for apertures located far outside the image. Parameters ---------- cx, cy : double The aperture center, in data pixel coordinates. ext_x, ext_y : double Half the width and half the height of the aperture's bounding box, in pixels. off_x, off_y : double The (x, y) offset of the bounding-box center from the aperture center, in pixels. This is zero for an aperture whose bounding box is centered on the aperture position. nx_data, ny_data : Py_ssize_t The shape of the data array in the x and y direction. gxmin, gymin : double * Output. The lower grid edges relative to the aperture center. dx, dy : double * Output. The pixel spacing of the grid. For the current pixel grid, ``dx`` and ``dy`` are always equal to 1.0. pixel_radius : double * Output. Half the pixel diagonal. norm : double * Output. The inverse pixel area. ixmin, iymin : Py_ssize_t * Output. The unclipped bounding-box origin. ix0, ix1, iy0, iy1 : Py_ssize_t * Output. The bounding-box limits, clipped to the image. Returns ------- result : bint `False` if the aperture bounding box does not overlap the image, in which case the output values are unspecified. Otherwise, returns `True` and writes the grid and bounding-box values described above through the output pointers. """ cdef double ixmin_d = floor(cx + off_x - ext_x + 0.5) cdef double ixmax_d = ceil(cx + off_x + ext_x + 0.5) cdef double iymin_d = floor(cy + off_y - ext_y + 0.5) cdef double iymax_d = ceil(cy + off_y + ext_y + 0.5) cdef double gxmax, gymax cdef Py_ssize_t grid_nx, grid_ny if (ixmin_d >= nx_data or ixmax_d <= 0.0 or iymin_d >= ny_data or iymax_d <= 0.0): return False gxmin[0] = ixmin_d - 0.5 - cx gxmax = ixmax_d - 0.5 - cx gymin[0] = iymin_d - 0.5 - cy gymax = iymax_d - 0.5 - cy grid_nx = (ixmax_d - ixmin_d) grid_ny = (iymax_d - iymin_d) dx[0] = (gxmax - gxmin[0]) / grid_nx dy[0] = (gymax - gymin[0]) / grid_ny pixel_radius[0] = 0.5 * sqrt(dx[0] * dx[0] + dy[0] * dy[0]) norm[0] = 1.0 / (dx[0] * dy[0]) ixmin[0] = ixmin_d iymin[0] = iymin_d ix0[0] = fmax(ixmin_d, 0.0) ix1[0] = fmin(ixmax_d, nx_data) iy0[0] = fmax(iymin_d, 0.0) iy1[0] = fmin(iymax_d, ny_data) return True cdef inline Py_ssize_t _presize_packed_offsets( const double[:, ::1] positions, double ext_x, double ext_y, double off_x, double off_y, Py_ssize_t nx_data, Py_ssize_t ny_data, Py_ssize_t[::1] starts) noexcept nogil: """ Compute the starting offset of each source within packed per-pixel buffers. For each source, the clipped bounding-box area provides an upper bound on the number of pixels that may contribute. The cumulative sum of these areas defines both the size of the packed per-pixel buffers used by the batch drivers and the starting offset of each source's region within those buffers. This function performs only bounding-box and offset calculations (using the same bounding-box logic as ``_source_grid_setup``). It does not iterate over or evaluate individual pixels. Parameters ---------- positions : double[:, ::1] The (x, y) center of each source aperture, in data pixel coordinates, as an (N, 2) array. ext_x, ext_y : double Half the width and half the height of the aperture's bounding box, in pixels. All sources share the same extent. off_x, off_y : double The (x, y) offset of the bounding-box center from the aperture center, in pixels. All sources share the same offset. nx_data, ny_data : Py_ssize_t The shape of the data array in the x and y direction. starts : Py_ssize_t[::1] Output. The starting offset of each source's region within the packed per-pixel buffers. Returns ------- total : Py_ssize_t The total required length of the packed per-pixel buffers. """ cdef Py_ssize_t k, ix0, ix1, iy0, iy1, area, total = 0 cdef double cx, cy, ixmin_d, ixmax_d, iymin_d, iymax_d for k in range(positions.shape[0]): cx = positions[k, 0] cy = positions[k, 1] ixmin_d = floor(cx + off_x - ext_x + 0.5) ixmax_d = ceil(cx + off_x + ext_x + 0.5) iymin_d = floor(cy + off_y - ext_y + 0.5) iymax_d = ceil(cy + off_y + ext_y + 0.5) starts[k] = total if (ixmin_d >= nx_data or ixmax_d <= 0.0 or iymin_d >= ny_data or iymax_d <= 0.0): continue ix0 = fmax(ixmin_d, 0.0) ix1 = fmin(ixmax_d, nx_data) iy0 = fmax(iymin_d, 0.0) iy1 = fmin(iymax_d, ny_data) area = (ix1 - ix0) * (iy1 - iy0) if area > 0: total += area return total cdef inline double _circle_pixel_frac(double pxmin, double pymin, double dx, double dy, double pixel_radius, double r, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps a circle of radius ``r`` centered on the origin. This replicates the per-pixel logic of ``circular_overlap_grid``, including the bounding-box check and the interior/exterior fast path, so the result is identical to the grid function. Parameters ---------- pxmin, pymin : double The lower edges of the pixel, relative to the circle center. dx, dy : double The pixel width and height. pixel_radius : double Half the pixel diagonal, used by the interior/exterior fast path. r : double The radius of the circle. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the circle. """ cdef double pxmax = pxmin + dx cdef double pymax = pymin + dy cdef double pxcen, pycen, d2 # Bounding-box check if not (pxmax > -r - 0.5 * dx and pxmin < r + 0.5 * dx and pymax > -r - 0.5 * dy and pymin < r + 0.5 * dy): return 0.0 # Squared distance from circle center to pixel center pxcen = pxmin + dx * 0.5 pycen = pymin + dy * 0.5 d2 = pxcen * pxcen + pycen * pycen return circle_frac_from_d2(pxmin, pymin, pxmax, pymax, dx, dy, pixel_radius, d2, r, use_exact, subpixels) cdef inline double _circular_annulus_pixel_frac(double pxmin, double pymin, double dx, double dy, double pixel_radius, double r_in, double r_out, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps a circular annulus with inner radius ``r_in`` and outer radius ``r_out`` centered on the origin. The squared pixel-center distance and the outer bounding-box check are computed once and shared by the outer and inner boundary evaluations, avoiding the redundant setup that two independent ``_circle_pixel_frac`` calls would incur. Parameters ---------- pxmin, pymin : double The lower edges of the pixel, relative to the annulus center. dx, dy : double The pixel width and height. pixel_radius : double Half the pixel diagonal, used by the interior/exterior fast path. r_in, r_out : double The inner and outer radii of the annulus. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the annulus. The result is clamped at zero. An annulus overlap can never be negative, but subtracting the inner overlap from the outer overlap can otherwise yield a tiny negative value from floating-point noise. """ cdef double pxmax = pxmin + dx cdef double pymax = pymin + dy cdef double pxcen, pycen, d2, frac # Outer bounding-box check if not (pxmax > -r_out - 0.5 * dx and pxmin < r_out + 0.5 * dx and pymax > -r_out - 0.5 * dy and pymin < r_out + 0.5 * dy): return 0.0 # Squared distance from the annulus center to the pixel center (shared) pxcen = pxmin + dx * 0.5 pycen = pymin + dy * 0.5 d2 = pxcen * pxcen + pycen * pycen frac = (circle_frac_from_d2(pxmin, pymin, pxmax, pymax, dx, dy, pixel_radius, d2, r_out, use_exact, subpixels) - circle_frac_from_d2(pxmin, pymin, pxmax, pymax, dx, dy, pixel_radius, d2, r_in, use_exact, subpixels)) return frac if frac > 0.0 else 0.0 cdef inline double _ellipse_frac_core(double pxmin, double pymin, double pxmax, double pymax, double dx, double dy, double norm, double pxcen, double pycen, double rx, double ry, double cos_theta, double sin_theta, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps an ellipse with semiaxes ``rx``/``ry`` and orientation ``cos_theta``/``sin_theta``, centered on the origin. This is the shared core used by both ``_ellipse_pixel_frac`` and ``_elliptical_annulus_pixel_frac``, given the precomputed pixel center ``pxcen``/``pycen``. The caller is responsible for the bounding-box check. None is performed here. Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges, relative to the ellipse center. dx, dy : double The pixel width and height. norm : double The inverse pixel area, used to normalize the exact overlap calculation. pxcen, pycen : double The pixel center, relative to the ellipse center. rx, ry : double The semimajor and semiminor axes of the ellipse. cos_theta, sin_theta : double The cosine and sine of the ellipse's position angle. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the ellipse. """ cdef double inv_rx2 = 1.0 / (rx * rx) cdef double inv_ry2 = 1.0 / (ry * ry) cdef double cxx, cyy, cxy, margin, f_in, f_out, rpix2 # Quadratic-form coefficients and fast-path thresholds. These are # kept inline (rather than calling ``ellipse_quadratic_coeffs``) so # they stay in registers in this per-pixel hot path. The shared # decision core ``ellipse_frac_from_rpix2`` does the fast path and # exact/subpixel dispatch. cxx = cos_theta * cos_theta * inv_rx2 + sin_theta * sin_theta * inv_ry2 cyy = sin_theta * sin_theta * inv_rx2 + cos_theta * cos_theta * inv_ry2 cxy = 2.0 * cos_theta * sin_theta * (inv_rx2 - inv_ry2) margin = 0.5 * sqrt(dx * dx + dy * dy) / fmin(rx, ry) f_in = 1.0 - margin # Use a negative sentinel when no pixel can be wholly inside. # Clamping to 0 would misclassify a pixel whose center lies exactly # on the ellipse center (value 0) as wholly inside. f_in = f_in * f_in if f_in > 0.0 else -1.0 f_out = (1.0 + margin) * (1.0 + margin) rpix2 = cxx * pxcen * pxcen + cyy * pycen * pycen + cxy * pxcen * pycen return ellipse_frac_from_rpix2(pxmin, pymin, pxmax, pymax, norm, rx, ry, cos_theta, sin_theta, rpix2, f_in, f_out, use_exact, subpixels) cdef inline double _ellipse_pixel_frac(double pxmin, double pymin, double dx, double dy, double norm, double rx, double ry, double cos_theta, double sin_theta, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps an ellipse with semimajor and semiminor axes ``rx`` and ``ry`` and position angle ``theta``, centered on the origin. This replicates the per-pixel logic of ``elliptical_overlap_grid``, including the bounding-circle check and the interior/exterior fast path, so the result is identical to the grid function. Parameters ---------- pxmin, pymin : double The lower edges of the pixel, relative to the ellipse center. dx, dy : double The pixel width and height. norm : double The inverse pixel area, used to normalize the exact overlap calculation. rx, ry : double The semimajor and semiminor axes of the ellipse. cos_theta, sin_theta : double The cosine and sine of the ellipse's position angle ``theta``, precomputed once per aperture by the caller. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the ellipse. """ cdef double pxmax = pxmin + dx cdef double pymax = pymin + dy cdef double r = fmax(rx, ry) # bounding circle radius cdef double pxcen, pycen # Bounding-box check if not (pxmax > -r - 0.5 * dx and pxmin < r + 0.5 * dx and pymax > -r - 0.5 * dy and pymin < r + 0.5 * dy): return 0.0 pxcen = pxmin + 0.5 * dx pycen = pymin + 0.5 * dy return _ellipse_frac_core(pxmin, pymin, pxmax, pymax, dx, dy, norm, pxcen, pycen, rx, ry, cos_theta, sin_theta, use_exact, subpixels) cdef inline double _elliptical_annulus_pixel_frac( double pxmin, double pymin, double dx, double dy, double norm, double rx_in, double ry_in, double rx_out, double ry_out, double cos_theta, double sin_theta, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps an elliptical annulus with inner semiaxes ``rx_in``/``ry_in`` and outer semiaxes ``rx_out``/``ry_out`` (sharing orientation ``cos_theta``/``sin_theta``), centered on the origin. The pixel center and the outer bounding-box check are computed once and shared by the outer and inner boundary evaluations. Parameters ---------- pxmin, pymin : double The lower edges of the pixel, relative to the annulus center. dx, dy : double The pixel width and height. norm : double The inverse pixel area, used to normalize the exact overlap calculation. rx_in, ry_in : double The inner semimajor and semiminor axes of the annulus. rx_out, ry_out : double The outer semimajor and semiminor axes of the annulus. cos_theta, sin_theta : double The cosine and sine of the shared position angle ``theta``. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the annulus. The result is clamped at zero to remove tiny negative values from floating-point noise (an annulus overlap can never be negative). """ cdef double pxmax = pxmin + dx cdef double pymax = pymin + dy cdef double r = fmax(rx_out, ry_out) # outer bounding circle radius cdef double pxcen, pycen, frac # Outer bounding-box check if not (pxmax > -r - 0.5 * dx and pxmin < r + 0.5 * dx and pymax > -r - 0.5 * dy and pymin < r + 0.5 * dy): return 0.0 pxcen = pxmin + 0.5 * dx pycen = pymin + 0.5 * dy frac = (_ellipse_frac_core(pxmin, pymin, pxmax, pymax, dx, dy, norm, pxcen, pycen, rx_out, ry_out, cos_theta, sin_theta, use_exact, subpixels) - _ellipse_frac_core(pxmin, pymin, pxmax, pymax, dx, dy, norm, pxcen, pycen, rx_in, ry_in, cos_theta, sin_theta, use_exact, subpixels)) return frac if frac > 0.0 else 0.0 cdef inline double _rect_frac_core(double pxmin, double pymin, double pxmax, double pymax, double dx, double dy, double margin, double half_width, double half_height, double cos_theta, double sin_theta, double bbox_dx, double bbox_dy, double axrot, double ayrot, double *poly_x, double *poly_y, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps a rotated rectangle centered on the origin. This is the shared core used by both ``_rect_pixel_frac`` and ``_rectangular_annulus_pixel_frac``, so the rotated pixel center is computed only once per pixel by the caller and passed in as ``axrot``/``ayrot`` (used only by the exact interior/exterior fast path). Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges, relative to the rectangle center. dx, dy : double The pixel width and height. margin : double Half the pixel diagonal. half_width, half_height : double Half the width and half the height of the rectangle. cos_theta, sin_theta : double The cosine and sine of the rectangle's rotation angle. bbox_dx, bbox_dy : double Half the width and half the height of the rectangle's axis-aligned bounding box. axrot, ayrot : double The rotated pixel-center distances from the rectangle center, precomputed by the caller. Used only when ``use_exact`` is 1. poly_x, poly_y : double * Scratch buffers for the rectangle's polygon vertices, used by the general (non-fast-path) exact overlap calculation. buf_a_x, buf_a_y, buf_b_x, buf_b_y : double * Scratch buffers used by the polygon-clipping routine. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the rectangle. """ if use_exact: # Bounding-box check if (pxmax <= -bbox_dx or pxmin >= bbox_dx or pymax <= -bbox_dy or pymin >= bbox_dy): return 0.0 if axrot >= half_width + margin or ayrot >= half_height + margin: return 0.0 # wholly outside if (axrot <= half_width - margin and ayrot <= half_height - margin): return 1.0 # wholly inside return polygon_pixel_overlap(pxmin, pymin, pxmax, pymax, poly_x, poly_y, 4, buf_a_x, buf_a_y, buf_b_x, buf_b_y, 32) / (dx * dy) return rectangle_overlap_single_subpixel(pxmin, pymin, pxmax, pymax, half_width, half_height, cos_theta, sin_theta, subpixels) cdef inline double _rect_pixel_frac(double pxmin, double pymin, double dx, double dy, double margin, double half_width, double half_height, double cos_theta, double sin_theta, double bbox_dx, double bbox_dy, double *poly_x, double *poly_y, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps a rectangle of full width ``2 * half_width`` and full height ``2 * half_height``, rotated by ``theta`` (given as ``cos_theta``/``sin_theta``) and centered on the origin. This replicates the per-pixel logic of ``rectangular_overlap_grid``. The exact mode uses an interior/exterior fast path and skips pixels outside the axis-aligned bounding box of the rotated rectangle, so the result is identical to the grid function. Parameters ---------- pxmin, pymin : double The lower edges of the pixel, relative to the rectangle center. dx, dy : double The pixel width and height. margin : double Half the pixel diagonal. half_width, half_height : double Half the width and half the height of the rectangle. cos_theta, sin_theta : double The cosine and sine of the rectangle's rotation angle ``theta``. bbox_dx, bbox_dy : double Half the width and half the height of the rectangle's axis-aligned bounding box. poly_x, poly_y : double * Scratch buffers for the rectangle's polygon vertices, used by the general (non-fast-path) exact overlap calculation. buf_a_x, buf_a_y, buf_b_x, buf_b_y : double * Scratch buffers used by the polygon-clipping routine. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the rectangle. """ cdef double pxmax = pxmin + dx cdef double pymax = pymin + dy cdef double pxcen, pycen, axrot = 0.0, ayrot = 0.0 if use_exact: # Rotation into the rectangle frame is an isometry, so every # point of the pixel lies within ``margin`` of the rotated pixel # center. pxcen = pxmin + 0.5 * dx pycen = pymin + 0.5 * dy axrot = fabs(pxcen * cos_theta + pycen * sin_theta) ayrot = fabs(-pxcen * sin_theta + pycen * cos_theta) return _rect_frac_core(pxmin, pymin, pxmax, pymax, dx, dy, margin, half_width, half_height, cos_theta, sin_theta, bbox_dx, bbox_dy, axrot, ayrot, poly_x, poly_y, buf_a_x, buf_a_y, buf_b_x, buf_b_y, use_exact, subpixels) cdef inline double _rectangular_annulus_pixel_frac( double pxmin, double pymin, double dx, double dy, double margin, double half_width_in, double half_height_in, double half_width_out, double half_height_out, double cos_theta, double sin_theta, double bbox_dx_in, double bbox_dy_in, double bbox_dx_out, double bbox_dy_out, double *poly_x_in, double *poly_y_in, double *poly_x_out, double *poly_y_out, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps a rectangular annulus (sharing orientation ``cos_theta``/``sin_theta``), centered on the origin. The rotated pixel center is computed once and shared by the outer and inner boundary evaluations. Parameters ---------- pxmin, pymin : double The lower edges of the pixel, relative to the annulus center. dx, dy : double The pixel width and height. margin : double Half the pixel diagonal. half_width_in, half_height_in : double Half the width and half the height of the inner rectangle. half_width_out, half_height_out : double Half the width and half the height of the outer rectangle. cos_theta, sin_theta : double The cosine and sine of the shared rotation angle ``theta``. bbox_dx_in, bbox_dy_in : double Half the width and half the height of the inner rectangle's axis-aligned bounding box. bbox_dx_out, bbox_dy_out : double Half the width and half the height of the outer rectangle's axis-aligned bounding box. poly_x_in, poly_y_in : double * Scratch buffers for the inner rectangle's polygon vertices. poly_x_out, poly_y_out : double * Scratch buffers for the outer rectangle's polygon vertices. buf_a_x, buf_a_y, buf_b_x, buf_b_y : double * Scratch buffers used by the polygon-clipping routine, shared by the inner and outer boundary evaluations. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the annulus. The result is clamped at zero to remove tiny negative values from floating-point noise (an annulus overlap can never be negative). """ cdef double pxmax = pxmin + dx cdef double pymax = pymin + dy cdef double pxcen, pycen, axrot = 0.0, ayrot = 0.0, frac if use_exact: # Rotated pixel center, shared by both boundary evaluations. pxcen = pxmin + 0.5 * dx pycen = pymin + 0.5 * dy axrot = fabs(pxcen * cos_theta + pycen * sin_theta) ayrot = fabs(-pxcen * sin_theta + pycen * cos_theta) frac = (_rect_frac_core(pxmin, pymin, pxmax, pymax, dx, dy, margin, half_width_out, half_height_out, cos_theta, sin_theta, bbox_dx_out, bbox_dy_out, axrot, ayrot, poly_x_out, poly_y_out, buf_a_x, buf_a_y, buf_b_x, buf_b_y, use_exact, subpixels) - _rect_frac_core(pxmin, pymin, pxmax, pymax, dx, dy, margin, half_width_in, half_height_in, cos_theta, sin_theta, bbox_dx_in, bbox_dy_in, axrot, ayrot, poly_x_in, poly_y_in, buf_a_x, buf_a_y, buf_b_x, buf_b_y, use_exact, subpixels)) return frac if frac > 0.0 else 0.0 cdef inline double _polygon_pixel_frac(double pxmin, double pymin, double dx, double dy, double margin, double *poly_x, double *poly_y, int n_poly, double *edge_nx, double *edge_ny, double *edge_c, int is_convex, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int buf_size, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps a simple polygon, supplied as counterclockwise vertices centered on the origin. This replicates the per-pixel logic of ``polygon_overlap_grid``. Both modes use an interior/exterior fast path for convex polygons (``is_convex``). Boundary-band pixels are clipped against the polygon using the Sutherland-Hodgman algorithm in exact mode, or sampled at subpixel centers with point-in-polygon tests in subpixel mode. The result is identical to the grid function. Parameters ---------- pxmin, pymin : double The lower edges of the pixel, relative to the polygon center. dx, dy : double The pixel width and height. margin : double Half the pixel diagonal. poly_x, poly_y : double * The x and y coordinates of the polygon vertices, in counterclockwise order. n_poly : int The number of polygon vertices. edge_nx, edge_ny, edge_c : double * The precomputed edge normals and offsets used by the convex interior/exterior fast path. is_convex : int Set to 1 if the polygon is convex, which enables the interior/exterior fast path. Set to 0 for general (possibly nonconvex) polygons. buf_a_x, buf_a_y, buf_b_x, buf_b_y : double * Scratch buffers used by the polygon-clipping routine. buf_size : int The size of the scratch buffers. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the polygon. """ cdef double pxmax = pxmin + dx cdef double pymax = pymin + dy if use_exact: return convex_polygon_pixel_overlap( pxmin, pymin, pxmax, pymax, poly_x, poly_y, n_poly, edge_nx, edge_ny, edge_c, is_convex, margin, buf_a_x, buf_a_y, buf_b_x, buf_b_y, buf_size) / (dx * dy) return convex_polygon_pixel_subpixel(pxmin, pymin, pxmax, pymax, poly_x, poly_y, n_poly, edge_nx, edge_ny, edge_c, is_convex, margin, subpixels, buf_a_x, buf_a_y, buf_b_x) astropy-photutils-24be529/photutils/aperture/_batch_photometry.pyx000066400000000000000000001133431526452531300257530ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Tools to provide a batch driver for aperture photometry. For each source position, the overlap fraction of the aperture with each pixel in the aperture bounding box is computed and immediately accumulated into the aperture sum, without materializing per-source mask arrays or making per-source Python calls. The per-pixel overlap fractions are computed with exactly the same arithmetic as the ``photutils.geometry`` grid functions, so the results agree with the mask-based photometry code path. The main source loop runs without the GIL and uses no global mutable state, so this module is safe to use from multiple threads, including on free-threaded Python builds. The image arrays are declared with fused types, so the driver reads C-contiguous float32 or float64 images and int32 or intp segmentation images directly, without the caller making full-image copies. The ``data`` and ``error`` images passed in one call must have the same dtype. The segmentation dtype is independent of it. Each pixel value is converted to double as it is read, which is exact, and all arithmetic is performed in double precision, so the results agree to within rounding for either input dtype. """ import numpy as np from photutils.aperture._batch_results import BatchApertureSums from photutils.aperture._batch_outside cimport _ShapeSpec, outside_weight from photutils.aperture._batch_overlap cimport ( _CIRCLE, _CIRCULAR_ANNULUS, _ELLIPSE, _ELLIPTICAL_ANNULUS, _POLYGON, _RECTANGLE, _RECTANGULAR_ANNULUS, _SEG_CORRECTED, _SEG_NEIGHBOR, _SEG_UNCORRECTED, _circle_pixel_frac, _circular_annulus_pixel_frac, _classify_seg_pixel, _ellipse_pixel_frac, _elliptical_annulus_pixel_frac, _polygon_pixel_frac, _presize_packed_offsets, _rect_pixel_frac, _rectangular_annulus_pixel_frac, _resolve_seg_pixel, _round_half_away, _seg_method_active, _source_grid_setup, real_t, seg_t) from photutils.geometry._polygon_overlap cimport (convex_edge_normals, polygon_work_partition, polygon_work_size) from photutils.geometry.rectangle_overlap cimport rect_vertices __all__ = ['batch_aperture_sums'] cdef extern from "math.h" nogil: double sin(double x) double cos(double x) double fabs(double x) double ceil(double x) double sqrt(double x) bint isfinite(double x) # Python-level aliases of the shape codes (the ``cdef enum`` is shared # with ``_batch_stats`` via ``_batch_overlap.pxd``) SHAPE_CIRCLE = _CIRCLE SHAPE_CIRCULAR_ANNULUS = _CIRCULAR_ANNULUS SHAPE_ELLIPSE = _ELLIPSE SHAPE_ELLIPTICAL_ANNULUS = _ELLIPTICAL_ANNULUS SHAPE_RECTANGLE = _RECTANGLE SHAPE_RECTANGULAR_ANNULUS = _RECTANGULAR_ANNULUS SHAPE_POLYGON = _POLYGON # Column indices of the per-source ``flag_counts`` arrays returned by # the batch drivers (see ``batch_aperture_sums`` and # ``photutils.aperture._batch_stats.batch_aperture_gather``) FLAG_COL_N_PIXELS = 0 FLAG_COL_MASKED = 1 FLAG_COL_NONFINITE_DATA = 2 FLAG_COL_NONFINITE_ERROR = 3 FLAG_COL_SEG = 4 FLAG_COL_UNCORRECTED = 5 FLAG_COL_VALID = 6 FLAG_COL_BBOX_CLIPPED = 7 FLAG_COL_SEG_MASKED = 8 FLAG_COL_UNCORRECTED_MASKED = 9 # The total number of ``flag_counts`` columns. Every producer of a # per-source flag-count array must allocate this many columns N_FLAG_COLS = 10 cdef int _check_params(int shape_code, const double[::1] params, const double[:, ::1] params_per_source, Py_ssize_t n_src, int emit_sum) except -1: """ Validate the shared and per-source aperture shape parameters. Exactly one of ``params`` and ``params_per_source`` must be input. This is kept out of ``batch_aperture_sums`` so that its error handling stays out of that function's per-pixel loop. Parameters ---------- shape_code : int The aperture shape code (see the module-level ``SHAPE_*`` constants). params : const double[::1] The shared aperture shape parameters, or `None`. params_per_source : const double[:, ::1] The per-source aperture shape parameters, or `None`. n_src : Py_ssize_t The number of source positions. emit_sum : int Whether the packed per-pixel member buffers are emitted. Returns ------- result : int Zero. A `ValueError` is raised if the parameters are invalid. """ if params_per_source is None: if params is None: msg = 'params must be given when params_per_source is None' raise ValueError(msg) return 0 if params is not None: msg = 'give params or params_per_source, not both' raise ValueError(msg) if shape_code not in (_CIRCLE, _ELLIPSE): msg = 'params_per_source supports only circle and ellipse shapes' raise ValueError(msg) if emit_sum: msg = 'params_per_source does not support emit_sum' raise ValueError(msg) if params_per_source.shape[0] != n_src: msg = 'params_per_source must have one row per position' raise ValueError(msg) if ((shape_code == _CIRCLE and params_per_source.shape[1] != 1) or (shape_code == _ELLIPSE and params_per_source.shape[1] != 3)): msg = 'params_per_source has the wrong column count' raise ValueError(msg) return 0 def batch_aperture_sums(const real_t[:, ::1] data, const real_t[:, ::1] error, const unsigned char[:, ::1] mask, const double[:, ::1] positions, int shape_code, const double[::1] params, double ext_x, double ext_y, double off_x, double off_y, int use_exact, int subpixels, const seg_t[:, ::1] segmentation=None, const Py_ssize_t[::1] labels=None, int seg_method=0, const double[::1] local_bkg=None, int emit_sum=0, const double[:, ::1] params_per_source=None, int mask_nonfinite=0): """ Compute aperture sums for many source positions in a single call. For each position, the aperture bounding box is computed in exactly the same way as `photutils.aperture.BoundingBox.from_float`, and the per-pixel overlap fractions within the bounding box (clipped to the data) are computed with exactly the same arithmetic as the `photutils.geometry` grid functions, so the resulting sums match the mask-based photometry code path. Pixels with a nonpositive overlap fraction or that are masked are excluded from the sums. Parameters ---------- data : 2D ndarray of float32 or float64 (C-contiguous) The data array. error : 2D ndarray of float32 or float64 (C-contiguous) or `None` The pixel-wise 1-sigma errors. Must have the same shape as ``data``. mask : 2D ndarray of uint8 (C-contiguous) or `None` A mask array where nonzero values indicate masked (excluded) pixels. Must have the same shape as ``data``. For the flag counts, bit 1 (value 1) marks input-masked pixels. A caller that does not use ``mask_nonfinite`` can set bit 2 (value 2) to mark the nonfinite data pixels itself. Any nonzero value excludes the pixel. positions : 2D ndarray of float64 (C-contiguous) The (x, y) source positions with shape ``(n_sources, 2)``. shape_code : int The aperture shape code: 0=circle, 1=circular annulus, 2=ellipse, 3=elliptical annulus, 4=rectangle, 5=rectangular annulus, 6=polygon (see the module-level ``SHAPE_*`` constants). params : 1D ndarray of float64 (C-contiguous) or `None` The aperture shape parameters, shared by all source positions. Must be `None` if ``params_per_source`` is input: * circle: ``(r,)`` * circular annulus: ``(r_in, r_out)`` * ellipse: ``(a, b, theta)`` * elliptical annulus: ``(a_in, b_in, a_out, b_out, theta)`` * rectangle: ``(w, h, theta)`` * rectangular annulus: ``(w_in, h_in, w_out, h_out, theta)`` * polygon: the flattened counterclockwise vertex offsets ``(x0, y0, x1, y1, ...)`` relative to each position (at least 3 vertices, i.e., 6 values) where ``theta`` is in radians. ext_x, ext_y : float The half-extents of the aperture minimal bounding box in the x and y directions (i.e., ``Aperture._xy_extents``). These values are ignored (and recomputed for each source) if ``params_per_source`` is input. off_x, off_y : float The (x, y) offset of the bounding-box center from each source position (i.e., ``Aperture._xy_bbox_offset``). This is zero for an aperture whose bounding box is centered on its position. use_exact : int Whether to compute exact overlap fractions (1) or use subpixel sampling (0). subpixels : int The number of subpixels in each dimension when ``use_exact`` is 0. segmentation : 2D ndarray of int32 or intp (C-contiguous) or `None`, \ optional A segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``data``. If `None`, no segmentation masking is applied. labels : 1D ndarray of intp (C-contiguous) or `None`, optional The target source label for each position with shape ``(n_sources,)``. A label of 0 disables segmentation masking for that source, except for method 3, which does not use the label. Required (not `None`) if ``segmentation`` is input. seg_method : int, optional The segmentation masking method. The default is 0. * 0: disables masking. * 1: excludes neighbor-source pixels (``(seg > 0) & (seg != label)``). * 2: excludes all pixels not assigned to the target source (``seg != label``). * 3: excludes all labeled pixels (``seg > 0``), the ``'background_only'`` method. The labels are ignored. * 4: replaces neighbor-source pixels with the values mirrored across the (rounded) aperture center (the symmetric ``'correct'`` method). A neighbor pixel whose mirror falls outside the aperture bounding box clipped to the data, is itself a neighbor, is masked, or is nonfinite (with ``mask_nonfinite``) is excluded instead of replaced. local_bkg : 1D ndarray of float64 (C-contiguous) or `None`, optional The per-source local background to subtract from each pixel value of that source. If `None`, no background is subtracted. emit_sum : int, optional If nonzero, also emit the packed per-pixel member buffers (``sum_values``, ``sum_fracs``, ``sum_errsq``, ``sum_counts``) needed to recompute the aperture sum, variance, and area after per-source sigma clipping. When zero (default), those four outputs are empty arrays. params_per_source : 2D ndarray of float64 (C-contiguous) or `None`, \ optional The per-source aperture shape parameters with shape ``(n_sources, k)``, used instead of the shared ``params`` vector (which must then be `None`). Only the circle (``k`` = 1, ``(r,)``) and ellipse (``k`` = 3, ``(a, b, theta)``) shapes are supported, and ``emit_sum`` must be zero. The bounding-box half-extents are computed from each source's own parameters, so the ``ext_x`` and ``ext_y`` inputs are ignored. mask_nonfinite : int, optional If nonzero, nonfinite ``data`` pixels are excluded (in addition to the ``mask`` pixels) before the segmentation masking is applied, and they are counted in the ``FLAG_COL_NONFINITE_DATA`` column. A method-4 neighbor pixel whose mirror pixel is nonfinite is then left uncorrected. When zero (default), nonfinite data values are left in the computation, where they corrupt the sum. Returns ------- result : `~photutils.aperture._batch_results.BatchApertureSums` A named tuple with the following fields (in order). sums : 1D ndarray of float64 The aperture sums. NaN where the aperture bounding box does not overlap the data. sum_vars : 1D ndarray of float64 The aperture error variances (the quadrature sum of the pixel variances weighted by the squared overlap fractions). NaN where ``error`` is `None` or the aperture bounding box does not overlap the data. The caller takes the square root to obtain the error. areas : 1D ndarray of float64 The total unmasked overlap area of the aperture (the sum of the overlap fractions). NaN where the aperture bounding box does not overlap the data. overlap : 1D ndarray of bool Whether the aperture bounding box overlaps with the data. starts : 1D ndarray of intp The per-source starting offset into the packed member buffers. Zeros unless ``emit_sum`` is nonzero. sum_values : 1D ndarray of float64 The packed per-pixel ``data - local_bkg`` values. Empty unless ``emit_sum`` is nonzero. sum_fracs : 1D ndarray of float64 The packed per-pixel overlap fractions. Empty unless ``emit_sum`` is nonzero. sum_errsq : 1D ndarray of float64 The packed per-pixel squared errors (zero where ``error`` is `None`). Empty unless ``emit_sum`` is nonzero. sum_counts : 1D ndarray of intp The per-source count of packed contributing pixels. Empty unless ``emit_sum`` is nonzero. flag_counts : 2D ndarray of intp Per-source pixel counts for the quality flags, with one row per source and the columns given by the module-level ``FLAG_COL_*`` constants: * ``FLAG_COL_N_PIXELS``: the number of nonzero-fraction pixels inside the data * ``FLAG_COL_MASKED``: the number of those pixels that are input-masked * ``FLAG_COL_NONFINITE_DATA``: the number of those pixels excluded as nonfinite data (by ``mask_nonfinite`` or by mask bit 2) that are not input-masked, plus 1 if the accumulated sum is nonfinite (see below) * ``FLAG_COL_NONFINITE_ERROR``: a 0/1 indicator of whether a contributing pixel has a nonfinite error (detected from the accumulated variance, always 0 if ``error`` is `None`) * ``FLAG_COL_SEG``: the number of those unmasked pixels that lie on a neighboring source (for method 3, on any labeled source) and were excluded or corrected by the segmentation masking. Background pixels excluded by method 2 are not counted * ``FLAG_COL_UNCORRECTED``: the number of those unmasked pixels that could not be corrected by the segmentation masking * ``FLAG_COL_VALID``: the number of contributing pixels (unmasked, not excluded by the segmentation masking, and finite when ``mask_nonfinite`` is nonzero) * ``FLAG_COL_BBOX_CLIPPED``: a 0/1 indicator of whether the bounding box is clipped by a data edge * ``FLAG_COL_SEG_MASKED``: the number of those masked pixels that lie on a neighboring source * ``FLAG_COL_UNCORRECTED_MASKED``: the number of those masked pixels whose mirror pixel was also unavailable Masked pixels are excluded before the segmentation masking is applied, so they are counted in the ``FLAG_COL_SEG_MASKED`` and ``FLAG_COL_UNCORRECTED_MASKED`` columns instead of the ``FLAG_COL_SEG`` and ``FLAG_COL_UNCORRECTED`` columns. This lets callers that treat the mask and neighbor overlays independently recover the full neighbor-pixel counts. The ``FLAG_COL_NONFINITE_DATA`` and ``FLAG_COL_NONFINITE_ERROR`` columns are nonzero when nonfinite data or error values contribute to the aperture. Nonfinite data pixels excluded by ``mask_nonfinite`` or by mask bit 2 are counted exactly, while unmasked nonfinite contributions are detected from the accumulated sums as a 0/1 indicator. Rows are all zero where the aperture bounding box does not overlap the data. weights_out : 1D ndarray of uint8 A per-source 0/1 indicator of whether the aperture has one or more nonzero-fraction pixels outside the data. This is the precise outside-weight test, computed only for the sources whose bounding box is clipped by a data edge (the ``FLAG_COL_BBOX_CLIPPED`` column). It is exactly zero for unclipped sources and for sources whose bounding box does not overlap the data at all. For the exact overlap method, an aperture with a nonzero-fraction pixel inside the data is tested only on the ring of pixels just outside the data edges (see ``_batch_outside.outside_weight``), which is equivalent because every aperture shape is a connected region. """ cdef Py_ssize_t n_src = positions.shape[0] cdef Py_ssize_t ny_data = data.shape[0] cdef Py_ssize_t nx_data = data.shape[1] sums_arr = np.full(n_src, np.nan) vars_arr = np.full(n_src, np.nan) areas_arr = np.full(n_src, np.nan) overlap_arr = np.zeros(n_src, dtype=np.uint8) starts_arr = np.zeros(n_src, dtype=np.intp) fcounts_arr = np.zeros((n_src, N_FLAG_COLS), dtype=np.intp) wout_arr = np.zeros(n_src, dtype=np.uint8) cdef double[::1] sums = sums_arr cdef double[::1] sum_vars = vars_arr cdef double[::1] areas = areas_arr cdef unsigned char[::1] overlap = overlap_arr cdef Py_ssize_t[::1] starts = starts_arr cdef Py_ssize_t[:, ::1] fcounts = fcounts_arr cdef unsigned char[::1] weights_out = wout_arr cdef bint has_error = error is not None cdef bint has_mask = mask is not None cdef bint has_seg = segmentation is not None cdef bint has_bkg = local_bkg is not None cdef Py_ssize_t lbl = 0 cdef bint seg_active = False # Base pointers for the C-contiguous segmentation and mask planes, # used by the shared per-pixel segmentation helper cdef const seg_t *seg_ptr = NULL cdef const unsigned char *mask_ptr = NULL if has_seg: seg_ptr = &segmentation[0, 0] if has_mask: mask_ptr = &mask[0, 0] # Aperture shape parameters (constant over all source positions) cdef double r_in = 0.0, r_out = 0.0 cdef double rx_in = 0.0, ry_in = 0.0, rx_out = 0.0, ry_out = 0.0 cdef double theta = 0.0, cos_theta = 1.0, sin_theta = 0.0 cdef double half_width_in = 0.0, half_height_in = 0.0 cdef double half_width_out = 0.0, half_height_out = 0.0 cdef double bbox_dx_in = 0.0, bbox_dy_in = 0.0 cdef double bbox_dx_out = 0.0, bbox_dy_out = 0.0 cdef double poly_x_in[4] cdef double poly_y_in[4] cdef double poly_x_out[4] cdef double poly_y_out[4] # Scratch buffers for the polygon clipping (rectangular apertures). # These are local to this call, so this function is thread-safe. cdef double buf_a_x[32] cdef double buf_a_y[32] cdef double buf_b_x[32] cdef double buf_b_y[32] # Working buffers for arbitrary-polygon apertures. The vertex count # is variable, so these are allocated as a single numpy block (kept # alive by ``poly_work``) and accessed through raw pointers. They # are local to this call, so this function is thread-safe. cdef int n_poly = 0, poly_buf_size = 0 cdef int is_poly_convex = 0 cdef Py_ssize_t pk cdef double[::1] poly_work cdef double *poly_x = NULL cdef double *poly_y = NULL cdef double *pbuf_a_x = NULL cdef double *pbuf_a_y = NULL cdef double *pbuf_b_x = NULL cdef double *pbuf_b_y = NULL cdef double *pedge_nx = NULL cdef double *pedge_ny = NULL cdef double *pedge_c = NULL cdef bint has_psrc = params_per_source is not None _check_params(shape_code, params, params_per_source, n_src, emit_sum) if not has_psrc: if shape_code == _CIRCLE: r_out = params[0] elif shape_code == _CIRCULAR_ANNULUS: r_in = params[0] r_out = params[1] elif shape_code == _ELLIPSE: rx_out = params[0] ry_out = params[1] theta = params[2] cos_theta = cos(theta) sin_theta = sin(theta) elif shape_code == _ELLIPTICAL_ANNULUS: rx_in = params[0] ry_in = params[1] rx_out = params[2] ry_out = params[3] theta = params[4] cos_theta = cos(theta) sin_theta = sin(theta) elif shape_code == _RECTANGLE or shape_code == _RECTANGULAR_ANNULUS: if shape_code == _RECTANGLE: half_width_out = 0.5 * params[0] half_height_out = 0.5 * params[1] theta = params[2] else: half_width_in = 0.5 * params[0] half_height_in = 0.5 * params[1] half_width_out = 0.5 * params[2] half_height_out = 0.5 * params[3] theta = params[4] cos_theta = cos(theta) sin_theta = sin(theta) rect_vertices(half_width_out, half_height_out, cos_theta, sin_theta, poly_x_out, poly_y_out) bbox_dx_out = (half_width_out * fabs(cos_theta) + half_height_out * fabs(sin_theta)) bbox_dy_out = (half_width_out * fabs(sin_theta) + half_height_out * fabs(cos_theta)) if shape_code == _RECTANGULAR_ANNULUS: rect_vertices(half_width_in, half_height_in, cos_theta, sin_theta, poly_x_in, poly_y_in) bbox_dx_in = (half_width_in * fabs(cos_theta) + half_height_in * fabs(sin_theta)) bbox_dy_in = (half_width_in * fabs(sin_theta) + half_height_in * fabs(cos_theta)) elif shape_code == _POLYGON: # ``params`` holds the flattened counterclockwise vertex # offsets (x0, y0, x1, y1, ...). n_poly = params.shape[0] // 2 if n_poly < 3 or 2 * n_poly != params.shape[0]: msg = ('polygon params must be the flattened (x, y) offsets ' 'of at least 3 vertices') raise ValueError(msg) # Single numpy block (kept alive by ``poly_work``) whose sizing # and layout are shared with ``polygon_overlap_grid`` via # ``polygon_work_size``/``polygon_work_partition``. poly_work = np.empty(polygon_work_size(n_poly, &poly_buf_size), dtype=np.float64) polygon_work_partition(&poly_work[0], n_poly, poly_buf_size, &poly_x, &poly_y, &pbuf_a_x, &pbuf_a_y, &pbuf_b_x, &pbuf_b_y, &pedge_nx, &pedge_ny, &pedge_c) for pk in range(n_poly): poly_x[pk] = params[2 * pk] poly_y[pk] = params[2 * pk + 1] # One-time convexity test. Convex polygons use an # interior/exterior fast path in ``_polygon_pixel_frac``. is_poly_convex = convex_edge_normals(poly_x, poly_y, n_poly, pedge_nx, pedge_ny, pedge_c) else: msg = f'Invalid shape_code: {shape_code}' raise ValueError(msg) # The shape parameters and scratch buffers as seen by the # outside-weight scan (off the hot path). The per-source shape # parameters, if any, are copied in before each scan. cdef _ShapeSpec sp sp.shape_code = shape_code sp.use_exact = use_exact sp.subpixels = subpixels sp.r_in = r_in sp.rx_in = rx_in sp.ry_in = ry_in sp.half_width_in = half_width_in sp.half_height_in = half_height_in sp.half_width_out = half_width_out sp.half_height_out = half_height_out sp.bbox_dx_in = bbox_dx_in sp.bbox_dy_in = bbox_dy_in sp.bbox_dx_out = bbox_dx_out sp.bbox_dy_out = bbox_dy_out sp.poly_x_in = poly_x_in sp.poly_y_in = poly_y_in sp.poly_x_out = poly_x_out sp.poly_y_out = poly_y_out sp.buf_a_x = buf_a_x sp.buf_a_y = buf_a_y sp.buf_b_x = buf_b_x sp.buf_b_y = buf_b_y sp.poly_x = poly_x sp.poly_y = poly_y sp.n_poly = n_poly sp.poly_buf_size = poly_buf_size sp.is_poly_convex = is_poly_convex sp.pedge_nx = pedge_nx sp.pedge_ny = pedge_ny sp.pedge_c = pedge_c sp.pbuf_a_x = pbuf_a_x sp.pbuf_a_y = pbuf_a_y sp.pbuf_b_x = pbuf_b_x sp.pbuf_b_y = pbuf_b_y cdef Py_ssize_t k, ix, iy, ix0, ix1, iy0, iy1 cdef Py_ssize_t ixmin, iymin cdef Py_ssize_t six, siy, ccx = 0, ccy = 0 cdef double cx, cy, lbk = 0.0 cdef double gxmin, gymin cdef double dx, dy, pixel_radius, norm cdef double pxmin, pymin, frac, err_val, sum_val, var_val, area_val, val cdef double errsq = 0.0 cdef Py_ssize_t total = 0, spos = 0 cdef Py_ssize_t n_pix, n_masked, n_nonfin, n_nonfin_err cdef Py_ssize_t n_seg_px, n_uncorr, n_valid, clipped cdef Py_ssize_t n_seg_masked, n_unc_masked cdef Py_ssize_t ixmax_full, iymax_full cdef unsigned char mbits cdef int pix_class # Pass 1 (only when emitting the packed member buffers): size and # offset the packed buffers from the per-source clipped bounding-box # areas. This performs only bounding-box arithmetic. It does not # iterate over or evaluate individual pixels. if emit_sum: with nogil: total = _presize_packed_offsets(positions, ext_x, ext_y, off_x, off_y, nx_data, ny_data, starts) cdef Py_ssize_t sum_cap = total if emit_sum else 0 sum_values_arr = np.empty(sum_cap, dtype=np.float64) sum_fracs_arr = np.empty(sum_cap, dtype=np.float64) sum_errsq_arr = np.empty(sum_cap, dtype=np.float64) scounts_arr = np.zeros(n_src if emit_sum else 0, dtype=np.intp) cdef double[::1] sum_values = sum_values_arr cdef double[::1] sum_fracs = sum_fracs_arr cdef double[::1] sum_errsq = sum_errsq_arr cdef Py_ssize_t[::1] scounts = scounts_arr with nogil: for k in range(n_src): cx = positions[k, 0] cy = positions[k, 1] # Per-source shape parameters and bounding-box half-extents if has_psrc: if shape_code == _CIRCLE: r_out = params_per_source[k, 0] ext_x = r_out ext_y = r_out else: rx_out = params_per_source[k, 0] ry_out = params_per_source[k, 1] theta = params_per_source[k, 2] cos_theta = cos(theta) sin_theta = sin(theta) ext_x = sqrt(rx_out * rx_out * cos_theta * cos_theta + ry_out * ry_out * sin_theta * sin_theta) ext_y = sqrt(rx_out * rx_out * sin_theta * sin_theta + ry_out * ry_out * cos_theta * cos_theta) if has_bkg: lbk = local_bkg[k] if has_seg: lbl = labels[k] seg_active = _seg_method_active(seg_method, lbl) if seg_method == 4: # Center pixel for the symmetric 'correct' mirror ccx = _round_half_away(cx) ccy = _round_half_away(cy) # Bounding box, overlap test, and pixel grid, replicated # from the mask-based path (see ``_source_grid_setup``). The # sums stay NaN when there is no overlap. if not _source_grid_setup(cx, cy, ext_x, ext_y, off_x, off_y, nx_data, ny_data, &gxmin, &gymin, &dx, &dy, &pixel_radius, &norm, &ixmin, &iymin, &ix0, &ix1, &iy0, &iy1): continue overlap[k] = 1 # Whether the bounding box is clipped by a data edge. Only # these sources can have aperture weights outside the data, # which is tested separately after the accumulation loop. ixmax_full = ceil(cx + off_x + ext_x + 0.5) iymax_full = ceil(cy + off_y + ext_y + 0.5) if (ixmin < ix0 or ixmax_full > ix1 or iymin < iy0 or iymax_full > iy1): clipped = 1 # bounding box clipped by data edge else: clipped = 0 # bounding box fully inside data sum_val = 0.0 var_val = 0.0 area_val = 0.0 n_pix = 0 n_masked = 0 n_nonfin = 0 n_nonfin_err = 0 n_seg_px = 0 n_uncorr = 0 n_seg_masked = 0 n_unc_masked = 0 n_valid = 0 spos = starts[k] for iy in range(iy0, iy1): pymin = gymin + (iy - iymin) * dy for ix in range(ix0, ix1): pxmin = gxmin + (ix - ixmin) * dx # The shape dispatch is inlined over the local # variables here. The outside-weight scan carries # the same dispatch in ``_batch_outside``, kept in # a separate extension module so that each shape # helper has a single call site in this one (see # that module's docstring) if shape_code == _CIRCLE: frac = _circle_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, r_out, use_exact, subpixels) elif shape_code == _CIRCULAR_ANNULUS: frac = _circular_annulus_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, r_in, r_out, use_exact, subpixels) elif shape_code == _ELLIPSE: frac = _ellipse_pixel_frac( pxmin, pymin, dx, dy, norm, rx_out, ry_out, cos_theta, sin_theta, use_exact, subpixels) elif shape_code == _ELLIPTICAL_ANNULUS: frac = _elliptical_annulus_pixel_frac( pxmin, pymin, dx, dy, norm, rx_in, ry_in, rx_out, ry_out, cos_theta, sin_theta, use_exact, subpixels) elif shape_code == _RECTANGLE: frac = _rect_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, half_width_out, half_height_out, cos_theta, sin_theta, bbox_dx_out, bbox_dy_out, poly_x_out, poly_y_out, buf_a_x, buf_a_y, buf_b_x, buf_b_y, use_exact, subpixels) elif shape_code == _RECTANGULAR_ANNULUS: frac = _rectangular_annulus_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, half_width_in, half_height_in, half_width_out, half_height_out, cos_theta, sin_theta, bbox_dx_in, bbox_dy_in, bbox_dx_out, bbox_dy_out, poly_x_in, poly_y_in, poly_x_out, poly_y_out, buf_a_x, buf_a_y, buf_b_x, buf_b_y, use_exact, subpixels) else: frac = _polygon_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, poly_x, poly_y, n_poly, pedge_nx, pedge_ny, pedge_c, is_poly_convex, pbuf_a_x, pbuf_a_y, pbuf_b_x, pbuf_b_y, poly_buf_size, use_exact, subpixels) # Annulus fractions are a difference of two shapes, # so floating-point noise can leave a boundary # pixel's fraction a tiny negative value. Both the # mask-based path (via ``np.maximum``) and the batch # overlap functions clamp such fractions to zero, so # only strictly positive fractions contribute here. if frac <= 0.0: continue n_pix += 1 # With ``mask_nonfinite``, an unmasked nonfinite # data pixel is treated as mask bit 2. Only the # aperture pixels are tested, so no full-image pass # is needed. mbits = mask[iy, ix] if has_mask else 0 if (mask_nonfinite and mbits == 0 and not isfinite(data[iy, ix])): mbits = 2 if mbits != 0: if mbits & 1: n_masked += 1 elif mbits & 2: n_nonfin += 1 # Masked pixels never reach the segmentation # branch below, so count masked neighbor-segment # pixels (and their mirror availability) here # for callers that treat the mask and neighbor # overlays independently. if seg_active: pix_class = _classify_seg_pixel( seg_ptr, mask_ptr, nx_data, seg_method, lbl, ix, iy, ix0, ix1, iy0, iy1, ccx, ccy, &six, &siy) if (pix_class == _SEG_CORRECTED and mask_nonfinite and not isfinite(data[siy, six])): pix_class = _SEG_UNCORRECTED if pix_class in (_SEG_NEIGHBOR, _SEG_CORRECTED, _SEG_UNCORRECTED): n_seg_masked += 1 if pix_class == _SEG_UNCORRECTED: n_unc_masked += 1 continue six = ix siy = iy if (seg_active and not _resolve_seg_pixel( seg_ptr, mask_ptr, nx_data, seg_method, lbl, ix, iy, ix0, ix1, iy0, iy1, ccx, ccy, &six, &siy, &n_seg_px, &n_uncorr)): continue # A corrected neighbor pixel is the only pixel # read from a different (mirror) pixel. A pixel # that mirrors onto itself is a neighbor, so it is # never corrected. A nonfinite mirror pixel leaves # the neighbor pixel uncorrected. if (mask_nonfinite and (six != ix or siy != iy) and not isfinite(data[siy, six])): n_uncorr += 1 continue val = data[siy, six] - lbk n_valid += 1 sum_val += val * frac area_val += frac if has_error: err_val = error[siy, six] errsq = err_val * err_val var_val += errsq * frac * frac if emit_sum: sum_values[spos] = val sum_fracs[spos] = frac sum_errsq[spos] = errsq if has_error else 0.0 spos += 1 # Nonfinite error values, and nonfinite data values when # ``mask_nonfinite`` is 0, corrupt the accumulated sums, so # their presence is detected from the final sums. Nonfinite # data pixels excluded by ``mask_nonfinite`` are counted # exactly in the masked branch above. if not isfinite(sum_val): n_nonfin += 1 if has_error and not isfinite(var_val): n_nonfin_err += 1 if clipped: # The shape parameters that can vary per source sp.r_out = r_out sp.rx_out = rx_out sp.ry_out = ry_out sp.cos_theta = cos_theta sp.sin_theta = sin_theta weights_out[k] = outside_weight( &sp, n_pix > 0, gxmin, gymin, dx, dy, pixel_radius, norm, ixmin, iymin, ixmax_full, iymax_full, ix0, ix1, iy0, iy1) sums[k] = sum_val areas[k] = area_val if has_error: sum_vars[k] = var_val if emit_sum: scounts[k] = spos - starts[k] fcounts[k, 0] = n_pix fcounts[k, 1] = n_masked fcounts[k, 2] = n_nonfin fcounts[k, 3] = n_nonfin_err fcounts[k, 4] = n_seg_px fcounts[k, 5] = n_uncorr fcounts[k, 6] = n_valid fcounts[k, 7] = clipped fcounts[k, 8] = n_seg_masked fcounts[k, 9] = n_unc_masked return BatchApertureSums( sums=sums_arr, sum_vars=vars_arr, areas=areas_arr, overlap=overlap_arr.view(bool), starts=starts_arr, sum_values=sum_values_arr, sum_fracs=sum_fracs_arr, sum_errsq=sum_errsq_arr, sum_counts=scounts_arr, flag_counts=fcounts_arr, weights_out=wout_arr) astropy-photutils-24be529/photutils/aperture/_batch_results.py000066400000000000000000000033001526452531300250410ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Result containers of the batch aperture Cython drivers. """ from typing import NamedTuple import numpy as np __all__ = [] class BatchApertureSums(NamedTuple): """ The per-source results of ``batch_aperture_sums`` (see its docstring for the full description of each field). The fields are the driver's return values in order, so the result can also be unpacked or indexed as a plain tuple. """ sums: np.ndarray """The aperture sums (NaN where the bounding box does not overlap the data).""" sum_vars: np.ndarray """The aperture sum variances (NaN if ``error`` is `None`).""" areas: np.ndarray """The aperture areas within the data.""" overlap: np.ndarray """Whether the aperture bounding box overlaps the data (bool).""" starts: np.ndarray """The start offset of each source in the packed per-pixel buffers.""" sum_values: np.ndarray """The packed per-pixel background-subtracted values (empty unless ``emit_sum`` is nonzero).""" sum_fracs: np.ndarray """The packed per-pixel overlap fractions (empty unless ``emit_sum`` is nonzero).""" sum_errsq: np.ndarray """The packed per-pixel error variances (empty unless ``emit_sum`` is nonzero).""" sum_counts: np.ndarray """The number of packed per-pixel entries of each source (empty unless ``emit_sum`` is nonzero).""" flag_counts: np.ndarray """The ``(n_sources, N_FLAG_COLS)`` per-source flag counts (see the ``FLAG_COL_*`` constants).""" weights_out: np.ndarray """The per-source 0/1 indicator of nonzero aperture weights outside the data (uint8).""" astropy-photutils-24be529/photutils/aperture/_batch_stats.pyx000066400000000000000000002123161526452531300246770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Tools to provide a batch driver for aperture statistics. For each source position, this module iterates over the aperture bounding box once and, using exactly the same per-pixel overlap arithmetic as the `photutils.geometry` grid functions (and the `~photutils.aperture._batch_photometry` driver), packs the unmasked "center"-method pixel values into a single buffer. The ``sum_method`` aperture sum, error variance, and area are computed separately by ``batch_aperture_sums``. The packed value buffer is then reduced lazily by the higher tiers (moments, median, and other order statistics) without creating per-source mask arrays or making per-source Python calls. The main source loop runs without the GIL and uses no global mutable state, so this module is safe to use from multiple threads, including on free-threaded Python builds. The image arrays of ``batch_aperture_gather`` are declared with fused types, so it reads a C-contiguous float32 or float64 data image and an int32 or intp segmentation image directly, without the caller making full-image copies. The data and segmentation dtypes are independent. Each pixel value is converted to double as it is read, which is exact, and all arithmetic is performed in double precision, so the results agree to within rounding for either input dtype. """ import numpy as np from photutils.aperture._batch_photometry import N_FLAG_COLS from photutils.aperture._batch_overlap cimport ( _CIRCLE, _CIRCULAR_ANNULUS, _ELLIPSE, _ELLIPTICAL_ANNULUS, _POLYGON, _RECTANGLE, _RECTANGULAR_ANNULUS, _SEG_CORRECTED, _SEG_NEIGHBOR, _SEG_UNCORRECTED, _circle_pixel_frac, _circular_annulus_pixel_frac, _classify_seg_pixel, _ellipse_pixel_frac, _elliptical_annulus_pixel_frac, _polygon_pixel_frac, _presize_packed_offsets, _rect_pixel_frac, _rectangular_annulus_pixel_frac, _resolve_seg_pixel, _round_half_away, _seg_method_active, _source_grid_setup, real_t, seg_t) from photutils.geometry._polygon_overlap cimport (convex_edge_normals, polygon_work_partition, polygon_work_size) from photutils.geometry.rectangle_overlap cimport rect_vertices __all__ = ['batch_aperture_gather', 'batch_moments', 'batch_sort_values', 'batch_order_stats', 'batch_minmax', 'batch_mean_var', 'batch_mad', 'batch_biweight', 'batch_gini', 'batch_sigma_clip_center', 'batch_sigma_clip_stats', 'batch_sigma_clip_sum'] cdef extern from "math.h" nogil: double sin(double x) double cos(double x) double sqrt(double x) double fabs(double x) double ceil(double x) bint isfinite(double x) const double NAN # Scale factor that converts the median absolute deviation to a robust # estimate of the standard deviation (1 / scipy.stats.norm.ppf(0.75)). # This must match ``astropy.stats.mad_std``. cdef double _MAD_STD_SCALE = 1.482602218505602 cdef inline void _insertion_sort(double *a, Py_ssize_t lo, Py_ssize_t hi) noexcept nogil: """ Sort ``a[lo:hi]`` ascending in place by insertion (for short ranges). """ cdef Py_ssize_t i, j cdef double v for i in range(lo + 1, hi): v = a[i] j = i while j > lo and v < a[j - 1]: a[j] = a[j - 1] j -= 1 a[j] = v cdef void _heapsort(double *a, Py_ssize_t lo, Py_ssize_t hi) noexcept nogil: """ Sort ``a[lo:hi]`` ascending in place by heapsort (the introsort fallback that bounds the worst case at O(n log n)). """ cdef Py_ssize_t n = hi - lo cdef Py_ssize_t start, root, child, end cdef double v # Build the max-heap, then repeatedly move the maximum to the end start = n // 2 - 1 end = n while True: if start >= 0: root = start start -= 1 else: end -= 1 if end == 0: return v = a[lo + end] a[lo + end] = a[lo] a[lo] = v root = 0 # Sift the root down within the heap [0, end) while True: child = 2 * root + 1 if child >= end: break if child + 1 < end and a[lo + child] < a[lo + child + 1]: child += 1 if a[lo + root] < a[lo + child]: v = a[lo + root] a[lo + root] = a[lo + child] a[lo + child] = v root = child else: break cdef void _introsort(double *a, Py_ssize_t lo, Py_ssize_t hi, int depth) noexcept nogil: """ Sort ``a[lo:hi]`` ascending in place by introsort. This is a median-of-three quicksort with Bentley-McIlroy three-way partitioning, heapsort when the recursion depth budget is exhausted, and insertion sort for short ranges. The three-way partition keeps the two scanning indices of a Hoare partition (so distinct keys sort as fast) but parks the elements equal to the pivot at the ends of the range and swaps them into the middle afterwards, so runs of equal values (e.g., quantized or constant pixel values) are excluded from further sorting instead of degrading to the quadratic-swap behavior of a two-way partition. The smaller of the two remaining partitions is recursed into and the larger is looped over, which bounds the recursion depth by log2(n). """ cdef Py_ssize_t i, j, k, p, q, mid, last cdef double pivot, v while hi - lo > 16: if depth == 0: _heapsort(a, lo, hi) return depth -= 1 # Median of three, moved to a[lo] as the pivot last = hi - 1 mid = lo + (hi - lo) // 2 if a[mid] < a[lo]: v = a[mid] a[mid] = a[lo] a[lo] = v if a[last] < a[lo]: v = a[last] a[last] = a[lo] a[lo] = v if a[last] < a[mid]: v = a[last] a[last] = a[mid] a[mid] = v pivot = a[mid] a[mid] = a[lo] a[lo] = pivot # Bentley-McIlroy partition: a[lo + 1:p + 1] and a[q:hi] hold # the elements equal to the pivot found so far i = lo j = hi p = lo q = hi while True: i += 1 while a[i] < pivot: if i == last: break i += 1 j -= 1 while pivot < a[j]: if j == lo: break j -= 1 if i == j and a[i] == pivot: p += 1 v = a[p] a[p] = a[i] a[i] = v if i >= j: break v = a[i] a[i] = a[j] a[j] = v if a[i] == pivot: p += 1 v = a[p] a[p] = a[i] a[i] = v if a[j] == pivot: q -= 1 v = a[q] a[q] = a[j] a[j] = v # Swap the parked equal elements into the middle, giving # a[lo:j + 1] < pivot, a[j + 1:i] == pivot, a[i:hi] > pivot i = j + 1 k = lo while k <= p: v = a[k] a[k] = a[j] a[j] = v k += 1 j -= 1 k = last while k >= q: v = a[k] a[k] = a[i] a[i] = v k -= 1 i += 1 if j + 1 - lo < hi - i: _introsort(a, lo, j + 1, depth) lo = i else: _introsort(a, i, hi, depth) hi = j + 1 _insertion_sort(a, lo, hi) def _heapsort_values(double[::1] values): """ Sort a 1D float64 array in place with the heapsort fallback of ``_sort_doubles``. The fallback runs only when the introsort recursion budget is exhausted, which no ordinary input reaches, so this entry point exists for the tests. Parameters ---------- values : 1D ndarray of float64 (C-contiguous, writable) The values to sort in place. """ if values.shape[0] > 1: _heapsort(&values[0], 0, values.shape[0]) cdef inline void _sort_doubles(double *a, Py_ssize_t n) noexcept nogil: """ Sort ``n`` finite doubles ascending in place. This replaces the C library ``qsort`` (whose comparison callback cannot be inlined and costs an indirect call per comparison) with an introsort whose comparisons compile to plain branches. The values must be finite. The ordering of NaN values is unspecified. """ cdef int depth = 0 cdef Py_ssize_t m = n if n < 2: return while m > 1: m >>= 1 depth += 1 _introsort(a, 0, n, 2 * depth) cdef inline double _median_sorted(double *s, Py_ssize_t n) noexcept nogil: """ Median of an ascending-sorted buffer. This matches ``np.median``. Parameters ---------- s : double * The ascending-sorted buffer. n : Py_ssize_t The number of elements in ``s``. Returns ------- result : double The median value. """ if n % 2 == 1: return s[n // 2] return 0.5 * (s[n // 2 - 1] + s[n // 2]) cdef inline double _mad_sorted(double *s, Py_ssize_t n) noexcept nogil: """ Unscaled median absolute deviation of an ascending-sorted buffer about its median. The absolute deviations of the values below the median decrease toward the median and those of the values at or above it increase away from it, so the sorted deviations are the merge of two ascending runs that start at the middle of the buffer. Walking the merge to its middle element gives exactly the median that sorting the deviations would give, in linear time and without a scratch buffer. Parameters ---------- s : double * The ascending-sorted buffer. n : Py_ssize_t The number of elements in ``s``. Must be positive. Returns ------- result : double The unscaled median absolute deviation. Multiply by ``_MAD_STD_SCALE`` to obtain `astropy.stats.mad_std`. """ cdef double center = _median_sorted(s, n) cdef Py_ssize_t half = n // 2 cdef Py_ssize_t lo = half - 1 # next element of the lower run cdef Py_ssize_t hi = half # next element of the upper run cdef Py_ssize_t count = 0 cdef double dev_lo = 0.0, dev_hi = 0.0, prev = 0.0, cur = 0.0 # Emit the half + 1 smallest deviations, keeping the last two. while count <= half: if lo >= 0: dev_lo = fabs(s[lo] - center) if hi < n: dev_hi = fabs(s[hi] - center) prev = cur if lo < 0 or (hi < n and dev_hi < dev_lo): cur = dev_hi hi += 1 else: cur = dev_lo lo -= 1 count += 1 if n % 2 == 1: return cur return 0.5 * (prev + cur) # Sigma-clip center/scale function codes (must match the ``cenfunc`` and # ``stdfunc`` mappings in ``ApertureStats`` and ``Background2D``). cdef enum: _CEN_MEDIAN = 0 _CEN_MEAN = 1 _CEN_BIWEIGHT = 2 _STD_STD = 0 _STD_MADSTD = 1 _STD_BIWEIGHT = 2 cdef inline void _sigma_clip_bounds(double *s, Py_ssize_t n, double sigma_lower, double sigma_upper, Py_ssize_t maxiters, int cenfunc_code, int stdfunc_code, double *out_min, double *out_max) noexcept nogil: """ Compute the final sigma-clip bounds for one source. Each iteration computes the center and scale of the values kept so far and keeps the values within ``center - sigma_lower * scale`` and ``center + sigma_upper * scale``, until an iteration drops nothing or ``maxiters`` iterations have run. The returned bounds are those of the final iteration, and a value ``v`` survives if ``not (v < out_min) and not (v > out_max)``. The survivors are therefore all of the values within the final bounds, for every center and scale function. A value dropped in an earlier iteration survives again when the final bounds admit it, which happens when the center moves by more than the scale shrinks. If an iteration drops every value, its bounds are final, in the same way as when it is the last allowed iteration. The center always lies within the range of the values it is computed from, so those bounds lie strictly between the smallest and largest of them and every value is clipped. For an empty input, the bounds are NaN. Parameters ---------- s : double * The ascending-sorted values, as ``s[0:n]``. n : Py_ssize_t The number of values in ``s``. sigma_lower, sigma_upper : double The lower and upper clipping limits in units of the scale. maxiters : Py_ssize_t The maximum number of clipping iterations, or a negative value to iterate until convergence. cenfunc_code : int The center function code (``_CEN_MEDIAN``, ``_CEN_MEAN``, or ``_CEN_BIWEIGHT``). stdfunc_code : int The scale function code (``_STD_STD``, ``_STD_MADSTD``, or ``_STD_BIWEIGHT``). out_min, out_max : double * Output. The final lower and upper value bounds. """ cdef Py_ssize_t lo = 0, hi = n, new_lo, new_hi, cnt, i, iteration = 0 cdef Py_ssize_t nchanged = 1 cdef double cen, std, mu, ss, med2, mad2, minv, maxv cdef bint biweight = (cenfunc_code == _CEN_BIWEIGHT or stdfunc_code == _STD_BIWEIGHT) minv = NAN maxv = NAN mad2 = NAN while nchanged != 0 and (maxiters < 0 or iteration < maxiters): iteration += 1 cnt = hi - lo if cnt == 0: break # The biweight center and scale need the median and the # unscaled MAD of the current values if biweight: med2 = _median_sorted(&s[lo], cnt) mad2 = _mad_sorted(&s[lo], cnt) # Center. if cenfunc_code == _CEN_MEDIAN: cen = _median_sorted(&s[lo], cnt) elif cenfunc_code == _CEN_BIWEIGHT: # astropy biweight_location with the default tuning # constant (c=6) and the median anchor. The median is # returned for zero MAD. if mad2 == 0.0: cen = med2 else: cen = _biweight_location_range(s, lo, hi, med2, 6.0, mad2) else: mu = 0.0 for i in range(lo, hi): mu += s[i] cen = mu / cnt # Scale. if stdfunc_code == _STD_STD: mu = 0.0 for i in range(lo, hi): mu += s[i] mu = mu / cnt ss = 0.0 for i in range(lo, hi): ss += (s[i] - mu) * (s[i] - mu) std = sqrt(ss / cnt) elif stdfunc_code == _STD_BIWEIGHT: # astropy biweight_scale with the default tuning constant # (c=9) and the median anchor. Zero is returned for zero # MAD. if mad2 == 0.0: std = 0.0 else: std = sqrt(_biweight_midvar_range(s, lo, hi, med2, 9.0, mad2)) else: std = _mad_sorted(&s[lo], cnt) * _MAD_STD_SCALE minv = cen - std * sigma_lower maxv = cen + std * sigma_upper new_lo = lo while new_lo < hi and s[new_lo] < minv: new_lo += 1 new_hi = hi while new_hi > new_lo and s[new_hi - 1] > maxv: new_hi -= 1 nchanged = (hi - lo) - (new_hi - new_lo) lo = new_lo hi = new_hi out_min[0] = minv out_max[0] = maxv def batch_aperture_gather(const real_t[:, ::1] data, const unsigned char[:, ::1] mask, const double[:, ::1] positions, int shape_code, const double[::1] params, double ext_x, double ext_y, double off_x, double off_y, const double[::1] local_bkg=None, const seg_t[:, ::1] segmentation=None, const Py_ssize_t[::1] labels=None, int seg_method=0, int mask_nonfinite=0): """ Gather the "center"-method pixel values for many source positions. For each position, the aperture bounding box is computed exactly as in `photutils.aperture.BoundingBox.from_float`, and the per-pixel overlap fractions are computed with exactly the same arithmetic as the `photutils.geometry` grid functions, so the results match the mask-based `~photutils.aperture.ApertureStats` code path. The "center" aperture mask method (``use_exact=0``, ``subpixels=1``) selects the unmasked pixel values used for the order and moment statistics. Each survivor is packed into a contiguous buffer. The ``sum_method`` aperture sum, error, and area are computed separately by `~photutils.aperture._batch_photometry.batch_aperture_sums`. Pixels that are masked or excluded by the segmentation masking are skipped. With ``mask_nonfinite``, nonfinite ``data`` pixels are skipped too, before any segmentation correction (matching the mask-based path). The local background ``local_bkg[k]`` is subtracted from each pixel value of source ``k``. Parameters ---------- data : 2D ndarray of float32 or float64 (C-contiguous) The data array (background not yet subtracted). mask : 2D ndarray of uint8 (C-contiguous) or `None` A mask array where nonzero values indicate masked pixels. For the flag counts, bit 1 (value 1) marks input-masked pixels. A caller that does not use ``mask_nonfinite`` can set bit 2 (value 2) to mark the nonfinite data pixels itself. Any nonzero value excludes the pixel. positions : 2D ndarray of float64 (C-contiguous) The ``(x, y)`` source positions with shape ``(n_sources, 2)``. shape_code : int The aperture shape code (see `_batch_photometry`). params : 1D ndarray of float64 (C-contiguous) The aperture shape parameters (see `_batch_photometry`). ext_x, ext_y : float The aperture bounding-box half-extents. off_x, off_y : float The (x, y) offset of the bounding-box center from each source position (see `_batch_photometry`). local_bkg : 1D ndarray of float64 (C-contiguous) or `None`, optional The per-source local background to subtract. If `None`, no background is subtracted. segmentation : 2D ndarray of int32 or intp (C-contiguous) or `None`, \ optional The segmentation image for segmentation-based masking. labels : 1D ndarray of intp (C-contiguous) or `None`, optional The per-source segmentation labels. seg_method : int, optional The segmentation masking method code (see `_batch_photometry`). The default is 0. mask_nonfinite : int, optional If nonzero, nonfinite ``data`` pixels are skipped (in addition to the ``mask`` pixels) before the segmentation masking is applied, and they are counted in the ``FLAG_COL_NONFINITE_DATA`` column. A method-4 neighbor pixel whose mirror pixel is nonfinite is then left uncorrected. When zero (default), nonfinite data values are gathered like any other value. Returns ------- values : 1D ndarray of float64 The packed buffer of unmasked "center"-method pixel values (background subtracted). The values for source ``k`` occupy ``values[starts[k]:starts[k] + counts[k]]``. local_x, local_y : 1D ndarray of int32 The packed pixel coordinates (relative to the clipped cutout origin) corresponding to ``values``, used for the image moments. Cutout coordinates always fit in 32 bits, and the narrower dtype reduces the packed-buffer memory footprint. starts : 1D ndarray of intp The start offset into ``values`` for each source. counts : 1D ndarray of intp The number of unmasked "center"-method pixels for each source (i.e., the center aperture area). overlap : 1D ndarray of bool Whether the aperture bounding box overlaps with the data. flag_counts : 2D ndarray of intp Per-source pixel counts for the quality flags, with one row per source and the columns given by the module-level ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`: * ``FLAG_COL_N_PIXELS``: the number of nonzero-fraction pixels inside the data * ``FLAG_COL_MASKED``: the number of those pixels that are input-masked * ``FLAG_COL_NONFINITE_DATA``: the number of those pixels excluded as nonfinite data (by ``mask_nonfinite`` or by mask bit 2) that are not input-masked. Nonfinite values gathered when ``mask_nonfinite`` is zero are not counted * ``FLAG_COL_NONFINITE_ERROR``: always zero (this function does not read error values) * ``FLAG_COL_SEG``: the number of those unmasked pixels that lie on a neighboring source (for method 3, on any labeled source) and were excluded or corrected by the segmentation masking. Background pixels excluded by method 2 are not counted * ``FLAG_COL_UNCORRECTED``: the number of those unmasked pixels that could not be corrected by the segmentation masking * ``FLAG_COL_VALID``: the number of gathered pixels, equal to ``counts`` (unmasked, not excluded by the segmentation masking, and finite when ``mask_nonfinite`` is nonzero) * ``FLAG_COL_BBOX_CLIPPED``: a 0/1 indicator of whether the bounding box is clipped by a data edge * ``FLAG_COL_SEG_MASKED``: the number of those masked pixels that lie on a neighboring source * ``FLAG_COL_UNCORRECTED_MASKED``: the number of those masked pixels whose mirror pixel was also unavailable Rows are all zero where the aperture bounding box does not overlap the data. """ cdef Py_ssize_t n_src = positions.shape[0] cdef Py_ssize_t ny_data = data.shape[0] cdef Py_ssize_t nx_data = data.shape[1] cdef bint has_mask = mask is not None cdef bint has_bkg = local_bkg is not None cdef bint has_seg = segmentation is not None cdef Py_ssize_t lbl = 0 cdef bint seg_active = False # Base pointers for the C-contiguous segmentation and mask planes, # used by the shared per-pixel segmentation helper cdef const seg_t *seg_ptr = NULL cdef const unsigned char *mask_ptr = NULL if has_seg: seg_ptr = &segmentation[0, 0] if has_mask: mask_ptr = &mask[0, 0] starts_arr = np.zeros(n_src, dtype=np.intp) counts_arr = np.zeros(n_src, dtype=np.intp) overlap_arr = np.zeros(n_src, dtype=np.uint8) fcounts_arr = np.zeros((n_src, N_FLAG_COLS), dtype=np.intp) cdef Py_ssize_t[::1] starts = starts_arr cdef Py_ssize_t[::1] counts = counts_arr cdef unsigned char[::1] overlap = overlap_arr cdef Py_ssize_t[:, ::1] fcounts = fcounts_arr # Aperture shape parameters (constant over all source positions) cdef double r_in = 0.0, r_out = 0.0 cdef double rx_in = 0.0, ry_in = 0.0, rx_out = 0.0, ry_out = 0.0 cdef double theta = 0.0, cos_theta = 1.0, sin_theta = 0.0 cdef double hw_in = 0.0, hh_in = 0.0, hw_out = 0.0, hh_out = 0.0 cdef double bdx_in = 0.0, bdy_in = 0.0, bdx_out = 0.0, bdy_out = 0.0 cdef double poly_x_in[4] cdef double poly_y_in[4] cdef double poly_x_out[4] cdef double poly_y_out[4] cdef double buf_a_x[32] cdef double buf_a_y[32] cdef double buf_b_x[32] cdef double buf_b_y[32] # Working buffers for arbitrary-polygon apertures (see # ``_batch_photometry``). cdef int n_poly = 0, poly_buf_size = 0 cdef int is_poly_convex = 0 cdef Py_ssize_t pk cdef double[::1] poly_work cdef double *poly_x = NULL cdef double *poly_y = NULL cdef double *pbuf_a_x = NULL cdef double *pbuf_a_y = NULL cdef double *pbuf_b_x = NULL cdef double *pbuf_b_y = NULL cdef double *pedge_nx = NULL cdef double *pedge_ny = NULL cdef double *pedge_c = NULL if shape_code == _CIRCLE: r_out = params[0] elif shape_code == _CIRCULAR_ANNULUS: r_in = params[0] r_out = params[1] elif shape_code == _ELLIPSE: rx_out = params[0] ry_out = params[1] theta = params[2] cos_theta = cos(theta) sin_theta = sin(theta) elif shape_code == _ELLIPTICAL_ANNULUS: rx_in = params[0] ry_in = params[1] rx_out = params[2] ry_out = params[3] theta = params[4] cos_theta = cos(theta) sin_theta = sin(theta) elif shape_code == _RECTANGLE or shape_code == _RECTANGULAR_ANNULUS: if shape_code == _RECTANGLE: hw_out = 0.5 * params[0] hh_out = 0.5 * params[1] theta = params[2] else: hw_in = 0.5 * params[0] hh_in = 0.5 * params[1] hw_out = 0.5 * params[2] hh_out = 0.5 * params[3] theta = params[4] cos_theta = cos(theta) sin_theta = sin(theta) rect_vertices(hw_out, hh_out, cos_theta, sin_theta, poly_x_out, poly_y_out) bdx_out = hw_out * fabs(cos_theta) + hh_out * fabs(sin_theta) bdy_out = hw_out * fabs(sin_theta) + hh_out * fabs(cos_theta) if shape_code == _RECTANGULAR_ANNULUS: rect_vertices(hw_in, hh_in, cos_theta, sin_theta, poly_x_in, poly_y_in) bdx_in = hw_in * fabs(cos_theta) + hh_in * fabs(sin_theta) bdy_in = hw_in * fabs(sin_theta) + hh_in * fabs(cos_theta) elif shape_code == _POLYGON: n_poly = params.shape[0] // 2 if n_poly < 3 or 2 * n_poly != params.shape[0]: msg = ('polygon params must be the flattened (x, y) offsets ' 'of at least 3 vertices') raise ValueError(msg) # Single numpy block (kept alive by ``poly_work``) whose sizing # and layout are shared with ``polygon_overlap_grid`` via # ``polygon_work_size``/``polygon_work_partition``. poly_work = np.empty(polygon_work_size(n_poly, &poly_buf_size), dtype=np.float64) polygon_work_partition(&poly_work[0], n_poly, poly_buf_size, &poly_x, &poly_y, &pbuf_a_x, &pbuf_a_y, &pbuf_b_x, &pbuf_b_y, &pedge_nx, &pedge_ny, &pedge_c) for pk in range(n_poly): poly_x[pk] = params[2 * pk] poly_y[pk] = params[2 * pk + 1] is_poly_convex = convex_edge_normals(poly_x, poly_y, n_poly, pedge_nx, pedge_ny, pedge_c) else: msg = f'Invalid shape_code: {shape_code}' raise ValueError(msg) cdef Py_ssize_t k, ix, iy, ix0, ix1, iy0, iy1 cdef Py_ssize_t ixmin, iymin cdef Py_ssize_t six, siy, ccx = 0, ccy = 0 cdef double cx, cy, lbk cdef double gxmin, gymin cdef double dx, dy, pixel_radius, norm cdef double pxmin, pymin, cfrac cdef Py_ssize_t total = 0, pos cdef Py_ssize_t n_pix, n_masked, n_nonfin, n_seg_px, n_uncorr, w_out cdef Py_ssize_t n_seg_masked, n_unc_masked cdef Py_ssize_t ixmax_full, iymax_full cdef unsigned char mbits cdef int pix_class # Pass 1: size and offset the packed value buffer from the # per-source clipped bounding-box areas (an upper bound on the # number of "center"-method survivors). This performs only # bounding-box arithmetic. It does not iterate over or evaluate # individual pixels. with nogil: total = _presize_packed_offsets(positions, ext_x, ext_y, off_x, off_y, nx_data, ny_data, starts) values_arr = np.empty(total, dtype=np.float64) lx_arr = np.empty(total, dtype=np.int32) ly_arr = np.empty(total, dtype=np.int32) cdef double[::1] values = values_arr cdef int[::1] local_x = lx_arr cdef int[::1] local_y = ly_arr # Pass 2: iterate over each bounding box once and gather the # "center"-method survivors into the packed buffer. with nogil: for k in range(n_src): cx = positions[k, 0] cy = positions[k, 1] lbk = 0.0 if has_bkg: lbk = local_bkg[k] if has_seg: lbl = labels[k] seg_active = _seg_method_active(seg_method, lbl) if seg_method == 4: # Center pixel for the symmetric 'correct' mirror ccx = _round_half_away(cx) ccy = _round_half_away(cy) # Bounding box, overlap test, and pixel grid, replicated # from the mask-based path (see ``_source_grid_setup``). if not _source_grid_setup(cx, cy, ext_x, ext_y, off_x, off_y, nx_data, ny_data, &gxmin, &gymin, &dx, &dy, &pixel_radius, &norm, &ixmin, &iymin, &ix0, &ix1, &iy0, &iy1): continue overlap[k] = 1 # Pixel values are loaded lazily (only for pixels inside # the aperture). n_pix = 0 n_masked = 0 n_nonfin = 0 n_seg_px = 0 n_uncorr = 0 n_seg_masked = 0 n_unc_masked = 0 pos = starts[k] for iy in range(iy0, iy1): pymin = gymin + (iy - iymin) * dy for ix in range(ix0, ix1): pxmin = gxmin + (ix - ixmin) * dx if shape_code == _CIRCLE: cfrac = _circle_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, r_out, 0, 1) elif shape_code == _CIRCULAR_ANNULUS: cfrac = _circular_annulus_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, r_in, r_out, 0, 1) elif shape_code == _ELLIPSE: cfrac = _ellipse_pixel_frac( pxmin, pymin, dx, dy, norm, rx_out, ry_out, cos_theta, sin_theta, 0, 1) elif shape_code == _ELLIPTICAL_ANNULUS: cfrac = _elliptical_annulus_pixel_frac( pxmin, pymin, dx, dy, norm, rx_in, ry_in, rx_out, ry_out, cos_theta, sin_theta, 0, 1) elif shape_code == _RECTANGLE: cfrac = _rect_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, hw_out, hh_out, cos_theta, sin_theta, bdx_out, bdy_out, poly_x_out, poly_y_out, buf_a_x, buf_a_y, buf_b_x, buf_b_y, 0, 1) elif shape_code == _RECTANGULAR_ANNULUS: cfrac = _rectangular_annulus_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, hw_in, hh_in, hw_out, hh_out, cos_theta, sin_theta, bdx_in, bdy_in, bdx_out, bdy_out, poly_x_in, poly_y_in, poly_x_out, poly_y_out, buf_a_x, buf_a_y, buf_b_x, buf_b_y, 0, 1) else: cfrac = _polygon_pixel_frac( pxmin, pymin, dx, dy, pixel_radius, poly_x, poly_y, n_poly, pedge_nx, pedge_ny, pedge_c, is_poly_convex, pbuf_a_x, pbuf_a_y, pbuf_b_x, pbuf_b_y, poly_buf_size, 0, 1) if cfrac <= 0.0: continue n_pix += 1 # With ``mask_nonfinite``, an unmasked nonfinite # data pixel is treated as mask bit 2. Only the # aperture pixels are tested, so no full-image pass # is needed. mbits = mask[iy, ix] if has_mask else 0 if (mask_nonfinite and mbits == 0 and not isfinite(data[iy, ix])): mbits = 2 if mbits != 0: if mbits & 1: n_masked += 1 elif mbits & 2: n_nonfin += 1 # Masked pixels never reach the segmentation # branch below, so count masked neighbor-segment # pixels (and their mirror availability) here # for callers that treat the mask and neighbor # overlays independently. if seg_active: pix_class = _classify_seg_pixel( seg_ptr, mask_ptr, nx_data, seg_method, lbl, ix, iy, ix0, ix1, iy0, iy1, ccx, ccy, &six, &siy) if (pix_class == _SEG_CORRECTED and mask_nonfinite and not isfinite(data[siy, six])): pix_class = _SEG_UNCORRECTED if pix_class in (_SEG_NEIGHBOR, _SEG_CORRECTED, _SEG_UNCORRECTED): n_seg_masked += 1 if pix_class == _SEG_UNCORRECTED: n_unc_masked += 1 continue six = ix siy = iy if (seg_active and not _resolve_seg_pixel( seg_ptr, mask_ptr, nx_data, seg_method, lbl, ix, iy, ix0, ix1, iy0, iy1, ccx, ccy, &six, &siy, &n_seg_px, &n_uncorr)): continue # A corrected neighbor pixel is the only pixel # read from a different (mirror) pixel. A pixel # that mirrors onto itself is a neighbor, so it is # never corrected. A nonfinite mirror pixel leaves # the neighbor pixel uncorrected. if (mask_nonfinite and (six != ix or siy != iy) and not isfinite(data[siy, six])): n_uncorr += 1 continue values[pos] = data[siy, six] - lbk local_x[pos] = (ix - ix0) local_y[pos] = (iy - iy0) pos += 1 counts[k] = pos - starts[k] # Whether the bounding box is clipped by a data edge. The # caller resolves this to the precise outside-weight test # (nonzero aperture weights outside the data) only for these # sources. Unclipped interior sources are exactly 0. ixmax_full = ceil(cx + off_x + ext_x + 0.5) iymax_full = ceil(cy + off_y + ext_y + 0.5) if (ixmin < ix0 or ixmax_full > ix1 or iymin < iy0 or iymax_full > iy1): w_out = 1 # bounding box clipped by a data edge else: w_out = 0 # bounding box fully inside the data fcounts[k, 0] = n_pix fcounts[k, 1] = n_masked fcounts[k, 2] = n_nonfin fcounts[k, 4] = n_seg_px fcounts[k, 5] = n_uncorr fcounts[k, 6] = counts[k] fcounts[k, 7] = w_out fcounts[k, 8] = n_seg_masked fcounts[k, 9] = n_unc_masked return (values_arr, lx_arr, ly_arr, starts_arr, counts_arr, overlap_arr.view(bool), fcounts_arr) def batch_moments(const double[::1] values, const int[::1] local_x, const int[::1] local_y, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts, const double[::1] cx, const double[::1] cy): """ Compute image moments up to 3rd order from a packed value buffer. For each source ``k``, the ``(4, 4)`` moment matrix is .. math:: M_{ij} = \\sum_p (y_p - c_y)^i \\, v_p \\, (x_p - c_x)^j where the sum is over the packed pixels ``p`` of source ``k`` (the slice ``[starts[k]:starts[k] + counts[k]]``), ``v_p`` is the pixel value, ``(x_p, y_p)`` are its cutout coordinates, and ``(c_x, c_y) = (cx[k], cy[k])``. This uses exactly the same definition as `photutils.utils._moments.image_moments`, so passing ``cx = cy = 0`` gives the raw moments and passing the cutout centroid gives the central moments. Parameters ---------- values : 1D ndarray of float64 The packed pixel values (see ``batch_aperture_gather``). local_x, local_y : 1D ndarray of int32 The packed pixel cutout coordinates. starts, counts : 1D ndarray of intp The per-source start offset into ``values`` and the per-source pixel count. cx, cy : 1D ndarray of float64 The per-source ``(x, y)`` moment center. Returns ------- moments : 3D ndarray of float64 The ``(n_sources, 4, 4)`` image moments. Sources with no pixels have all-zero moments. """ cdef Py_ssize_t n_src = starts.shape[0] moments_arr = np.zeros((n_src, 4, 4), dtype=np.float64) cdef double[:, :, ::1] moments = moments_arr cdef Py_ssize_t k, p, i, j, start, count cdef double cxk, cyk, val, dxp, dyp cdef double px[4] cdef double py[4] with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] cxk = cx[k] cyk = cy[k] for p in range(start, start + count): val = values[p] dxp = local_x[p] - cxk dyp = local_y[p] - cyk px[0] = 1.0 px[1] = dxp px[2] = dxp * dxp px[3] = px[2] * dxp py[0] = 1.0 py[1] = dyp py[2] = dyp * dyp py[3] = py[2] * dyp for i in range(4): for j in range(4): moments[k, i, j] += py[i] * val * px[j] return moments_arr def batch_sort_values(const double[::1] values, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts): """ Sort each source's packed pixel values in ascending order. For each source ``k`` the slice ``[starts[k]:starts[k] + counts[k]]`` of ``values`` is copied to the output buffer and sorted ascending. The sorted buffer is shared by the order statistics (``min``, ``max``, ``median``), ``mad_std``, and the biweight estimators, so the per-source sort is performed only once. Parameters ---------- values : 1D ndarray of float64 The packed pixel values (see ``batch_aperture_gather``). All values are assumed finite. starts, counts : 1D ndarray of intp The per-source start offset into ``values`` and the per-source pixel count. Returns ------- sorted_values : 1D ndarray of float64 The packed buffer whose per-source slice is sorted ascending. The ``starts`` and ``counts`` offsets are unchanged. """ cdef Py_ssize_t n_src = starts.shape[0] out_arr = np.empty(values.shape[0], dtype=np.float64) cdef double[::1] out = out_arr cdef Py_ssize_t k, i, start, count with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] for i in range(count): out[start + i] = values[start + i] _sort_doubles(&out[start], count) return out_arr def batch_order_stats(const double[::1] sorted_values, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts): """ Reduce a sorted packed buffer to the per-source min, max, and median. The conventions match `numpy.min`, `numpy.max`, and `numpy.median`. Sources with no pixels (``counts[k] == 0``) are set to NaN. Parameters ---------- sorted_values : 1D ndarray of float64 The packed per-source ascending-sorted values (see ``batch_sort_values``). starts, counts : 1D ndarray of intp The per-source start offset and pixel count. Returns ------- vmin, vmax, median : 1D ndarray of float64 The per-source statistics, each of shape ``(n_sources,)``. """ cdef Py_ssize_t n_src = starts.shape[0] vmin_arr = np.full(n_src, np.nan, dtype=np.float64) vmax_arr = np.full(n_src, np.nan, dtype=np.float64) median_arr = np.full(n_src, np.nan, dtype=np.float64) cdef double[::1] vmin = vmin_arr cdef double[::1] vmax = vmax_arr cdef double[::1] median = median_arr cdef Py_ssize_t k, start, count with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] vmin[k] = sorted_values[start] vmax[k] = sorted_values[start + count - 1] median[k] = _median_sorted(&sorted_values[start], count) return (vmin_arr, vmax_arr, median_arr) def batch_minmax(const double[::1] values, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts): """ Reduce a packed value buffer to the per-source minimum and maximum. Unlike `batch_order_stats`, this does not need (or produce) the sorted values, so it is the cheaper way to get only the extremes. The conventions match `numpy.min` and `numpy.max`. Sources with no pixels (``counts[k] == 0``) are set to NaN. Parameters ---------- values : 1D ndarray of float64 The packed pixel values (see ``batch_aperture_gather``). All values are assumed finite. starts, counts : 1D ndarray of intp The per-source start offset and pixel count. Returns ------- vmin, vmax : 1D ndarray of float64 The per-source minimum and maximum, each of shape ``(n_sources,)``. """ cdef Py_ssize_t n_src = starts.shape[0] vmin_arr = np.full(n_src, np.nan, dtype=np.float64) vmax_arr = np.full(n_src, np.nan, dtype=np.float64) cdef double[::1] vmin = vmin_arr cdef double[::1] vmax = vmax_arr cdef Py_ssize_t k, i, start, count cdef double v, lo, hi with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] lo = values[start] hi = lo for i in range(start + 1, start + count): v = values[i] if v < lo: lo = v elif v > hi: hi = v vmin[k] = lo vmax[k] = hi return vmin_arr, vmax_arr def batch_mean_var(const double[::1] values, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts): """ Reduce a packed value buffer to the per-source mean and variance. The conventions match `numpy.mean` and `numpy.var` (``ddof=0``). No sort is required. Sources with no pixels (``counts[k] == 0``) are set to NaN. Parameters ---------- values : 1D ndarray of float64 The packed pixel values (see ``batch_aperture_gather``). starts, counts : 1D ndarray of intp The per-source start offset and pixel count. Returns ------- mean, var : 1D ndarray of float64 The per-source mean and population variance. """ cdef Py_ssize_t n_src = starts.shape[0] mean_arr = np.full(n_src, np.nan, dtype=np.float64) var_arr = np.full(n_src, np.nan, dtype=np.float64) cdef double[::1] mean = mean_arr cdef double[::1] var = var_arr cdef Py_ssize_t k, i, start, count cdef double m, ssum, dsum, dval with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] ssum = 0.0 for i in range(start, start + count): ssum += values[i] m = ssum / count mean[k] = m dsum = 0.0 for i in range(start, start + count): dval = values[i] - m dsum += dval * dval var[k] = dsum / count return (mean_arr, var_arr) def batch_mad(const double[::1] sorted_values, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts): """ Reduce a sorted packed buffer to the per-source median absolute deviation. The returned value is the (unscaled) median of the absolute deviations from the per-source median. Multiply by the ``mad_std`` scale factor to obtain `astropy.stats.mad_std`. Sources with no pixels (``counts[k] == 0``) are set to NaN. Parameters ---------- sorted_values : 1D ndarray of float64 The packed per-source ascending-sorted values (see ``batch_sort_values``). starts, counts : 1D ndarray of intp The per-source start offset and pixel count. Returns ------- mad : 1D ndarray of float64 The per-source unscaled median absolute deviation. """ cdef Py_ssize_t n_src = starts.shape[0] mad_arr = np.full(n_src, np.nan, dtype=np.float64) cdef double[::1] mad = mad_arr cdef Py_ssize_t k, count with nogil: for k in range(n_src): count = counts[k] if count == 0: continue mad[k] = _mad_sorted(&sorted_values[starts[k]], count) return mad_arr def batch_biweight(const double[::1] sorted_values, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts, const double[::1] median, const double[::1] mad): """ Reduce a packed buffer to the per-source biweight location and midvariance. The conventions match `astropy.stats.biweight_location` (``c=6``) and `astropy.stats.biweight_midvariance` (``c=9``). The per-source ``median`` and (unscaled) ``mad`` are reused from ``batch_order_stats`` and ``batch_mad``. Sources with no pixels (``counts[k] == 0``) are set to NaN. Parameters ---------- sorted_values : 1D ndarray of float64 The packed per-source ascending-sorted values (see ``batch_sort_values``). The estimators are order-independent, so the sorted buffer is reused directly. starts, counts : 1D ndarray of intp The per-source start offset and pixel count. median : 1D ndarray of float64 The per-source median (see ``batch_order_stats``). mad : 1D ndarray of float64 The per-source unscaled median absolute deviation (see ``batch_mad``). Returns ------- biloc, bivar : 1D ndarray of float64 The per-source biweight location and midvariance. """ cdef Py_ssize_t n_src = starts.shape[0] biloc_arr = np.full(n_src, np.nan, dtype=np.float64) bivar_arr = np.full(n_src, np.nan, dtype=np.float64) cdef double[::1] biloc = biloc_arr cdef double[::1] bivar = bivar_arr cdef Py_ssize_t k, i, start, count cdef double med, madk, dval, uu, weight, num, den, u2, omu, f1, f2 with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] med = median[k] madk = mad[k] if madk == 0.0: biloc[k] = med bivar[k] = 0.0 continue num = 0.0 den = 0.0 f1 = 0.0 f2 = 0.0 for i in range(start, start + count): dval = sorted_values[i] - med uu = dval / (6.0 * madk) if fabs(uu) < 1.0: weight = (1.0 - uu * uu) weight = weight * weight num += dval * weight den += weight u2 = dval / (9.0 * madk) u2 = u2 * u2 if u2 < 1.0: omu = 1.0 - u2 f1 += dval * dval * omu * omu * omu * omu f2 += omu * (1.0 - 5.0 * u2) biloc[k] = med + num / den bivar[k] = count * f1 / (f2 * f2) return (biloc_arr, bivar_arr) def batch_gini(const double[::1] values, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts): """ Reduce a packed value buffer to the per-source Gini coefficient. The convention matches `photutils.morphology.gini`. Sources with no pixels (``counts[k] == 0``) are set to NaN. Parameters ---------- values : 1D ndarray of float64 The packed pixel values (see ``batch_aperture_gather``). The values need not be sorted. The absolute values are sorted internally. starts, counts : 1D ndarray of intp The per-source start offset and pixel count. Returns ------- gini : 1D ndarray of float64 The per-source Gini coefficient. """ cdef Py_ssize_t n_src = starts.shape[0] gini_arr = np.full(n_src, np.nan, dtype=np.float64) cdef double[::1] gini = gini_arr cdef Py_ssize_t maxn = 0, k for k in range(n_src): if counts[k] > maxn: maxn = counts[k] if maxn == 0: return gini_arr work_arr = np.empty(maxn, dtype=np.float64) cdef double[::1] w = work_arr cdef Py_ssize_t i, start, count cdef double gsum, meanabs, norm with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] if count == 1: gini[k] = 0.0 continue for i in range(count): w[i] = fabs(values[start + i]) _sort_doubles(&w[0], count) gsum = 0.0 for i in range(count): gsum += w[i] meanabs = gsum / count norm = meanabs * count * (count - 1) if norm == 0.0: gini[k] = 0.0 else: gsum = 0.0 for i in range(count): gsum += (2.0 * i + 1.0 - count) * w[i] gini[k] = gsum / norm return gini_arr def batch_sigma_clip_center(const double[::1] values, const int[::1] local_x, const int[::1] local_y, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts, double sigma_lower, double sigma_upper, Py_ssize_t maxiters, int cenfunc_code, int stdfunc_code): """ Sigma-clip each source's packed center buffer. For each source the packed pixel values (and their cutout coordinates) are sigma-clipped (see ``_sigma_clip_bounds``), and the surviving pixels are written to a new packed buffer that reuses the input ``starts`` offsets. The output can be fed directly to the packed-buffer reductions (e.g., ``batch_mean_var`` and ``batch_moments``). Because computing the clip bounds requires sorting each source's values, the ascending-sorted surviving values are also returned (the survivors form a contiguous window of the sorted buffer), so callers do not need to sort the clipped values again for the order statistics. Parameters ---------- values : 1D ndarray of float64 The packed center pixel values (see ``batch_aperture_gather``). local_x, local_y : 1D ndarray of int32 The packed pixel cutout coordinates. starts, counts : 1D ndarray of intp The per-source start offset and pixel count. sigma_lower, sigma_upper : float The lower and upper clipping limits in units of the scale. maxiters : int The maximum number of clipping iterations, or a negative value to iterate until convergence. cenfunc_code, stdfunc_code : int The center function code (0 = median, 1 = mean, 2 = biweight location) and the scale function code (0 = std, 1 = mad_std, 2 = biweight scale). Returns ------- values, local_x, local_y, starts, counts : 1D ndarray The clipped packed buffer (``starts`` is the input array, unchanged) and the per-source surviving pixel counts. sorted_values : 1D ndarray of float64 The packed buffer of ascending-sorted surviving values, using the same ``starts`` offsets and ``counts`` lengths. """ cdef Py_ssize_t n_src = starts.shape[0] out_values_arr = np.empty(values.shape[0], dtype=np.float64) out_lx_arr = np.empty(values.shape[0], dtype=np.int32) out_ly_arr = np.empty(values.shape[0], dtype=np.int32) out_sorted_arr = np.empty(values.shape[0], dtype=np.float64) counts2_arr = np.zeros(n_src, dtype=np.intp) cdef double[::1] out_values = out_values_arr cdef int[::1] out_lx = out_lx_arr cdef int[::1] out_ly = out_ly_arr cdef double[::1] out_sorted = out_sorted_arr cdef Py_ssize_t[::1] counts2 = counts2_arr cdef Py_ssize_t maxn = 0, k for k in range(n_src): if counts[k] > maxn: maxn = counts[k] if maxn == 0: return (out_values_arr, out_lx_arr, out_ly_arr, np.asarray(starts), counts2_arr, out_sorted_arr) sort_arr = np.empty(maxn, dtype=np.float64) cdef double[::1] s = sort_arr cdef Py_ssize_t start, count, i, j, lo, hi cdef double minv, maxv, v with nogil: for k in range(n_src): count = counts[k] if count == 0: continue start = starts[k] for i in range(count): s[i] = values[start + i] _sort_doubles(&s[0], count) _sigma_clip_bounds(&s[0], count, sigma_lower, sigma_upper, maxiters, cenfunc_code, stdfunc_code, &minv, &maxv) j = 0 for i in range(count): v = values[start + i] if not (v < minv) and not (v > maxv): out_values[start + j] = v out_lx[start + j] = local_x[start + i] out_ly[start + j] = local_y[start + i] j += 1 counts2[k] = j # The survivors form a contiguous window of the sorted # buffer (equal values always share the same fate). Emit # them sorted so that callers do not need to sort the # clipped values again. lo = 0 hi = count while lo < count and s[lo] < minv: lo += 1 while hi > lo and s[hi - 1] > maxv: hi -= 1 for i in range(lo, hi): out_sorted[start + (i - lo)] = s[i] return (out_values_arr, out_lx_arr, out_ly_arr, np.asarray(starts), counts2_arr, out_sorted_arr) def batch_sigma_clip_sum(const double[::1] sum_values, const double[::1] sum_fracs, const double[::1] sum_errsq, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] sum_counts, double sigma_lower, double sigma_upper, Py_ssize_t maxiters, int cenfunc_code, int stdfunc_code, int has_error): """ Sigma-clip each source's packed ``sum_method`` member buffer. The sum members are sigma-clipped (see ``_sigma_clip_bounds``) using the unweighted, background-subtracted pixel values, and the aperture sum, error variance, and area are recomputed over the surviving members. Sources with no sum members have NaN outputs. Parameters ---------- sum_values, sum_fracs, sum_errsq : 1D ndarray of float64 The packed ``sum_method`` member values, overlap fractions, and squared total errors (see `~photutils.aperture._batch_photometry.batch_aperture_sums` with ``emit_sum``). starts, sum_counts : 1D ndarray of intp The per-source start offset and member count. sigma_lower, sigma_upper : float The lower and upper clipping limits in units of the scale. maxiters : int The maximum number of clipping iterations, or a negative value to iterate until convergence. cenfunc_code, stdfunc_code : int The center function code (0 = median, 1 = mean, 2 = biweight location) and the scale function code (0 = std, 1 = mad_std, 2 = biweight scale). has_error : int Whether the squared errors are available. Returns ------- sum_aper, var_aper, sum_area : 1D ndarray of float64 The clipped aperture sum, error variance, and area. NaN where there are no sum members. """ cdef Py_ssize_t n_src = starts.shape[0] sum_arr = np.full(n_src, np.nan, dtype=np.float64) var_arr = np.full(n_src, np.nan, dtype=np.float64) area_arr = np.full(n_src, np.nan, dtype=np.float64) cdef double[::1] sum_aper = sum_arr cdef double[::1] var_aper = var_arr cdef double[::1] sum_area = area_arr cdef Py_ssize_t maxn = 0, k for k in range(n_src): if sum_counts[k] > maxn: maxn = sum_counts[k] if maxn == 0: return (sum_arr, var_arr, area_arr) sort_arr = np.empty(maxn, dtype=np.float64) cdef double[::1] s = sort_arr cdef Py_ssize_t start, count, i cdef double minv, maxv, v, frac, s_sum, s_area, s_var with nogil: for k in range(n_src): count = sum_counts[k] if count == 0: continue start = starts[k] for i in range(count): s[i] = sum_values[start + i] _sort_doubles(&s[0], count) _sigma_clip_bounds(&s[0], count, sigma_lower, sigma_upper, maxiters, cenfunc_code, stdfunc_code, &minv, &maxv) s_sum = 0.0 s_area = 0.0 s_var = 0.0 for i in range(count): v = sum_values[start + i] if not (v < minv) and not (v > maxv): frac = sum_fracs[start + i] s_sum += v * frac s_area += frac if has_error: s_var += sum_errsq[start + i] * frac * frac sum_aper[k] = s_sum sum_area[k] = s_area if has_error: var_aper[k] = s_var return (sum_arr, var_arr, area_arr) cdef inline double _biweight_location_range(double *s, Py_ssize_t lo, Py_ssize_t hi, double anchor, double c, double mad) noexcept nogil: """ Biweight location of the ascending-sorted values ``s[lo:hi]``. The convention matches `astropy.stats.biweight_location` with a scalar location anchor (``M``) and a nonzero unscaled median absolute deviation (``mad``). The caller must handle the ``mad == 0`` case (astropy returns the anchor). If every value is rejected (``|u| >= 1``), NaN is returned via the IEEE 0/0 division, matching astropy. Parameters ---------- s : double * The ascending-sorted values. lo, hi : Py_ssize_t The half-open index range of the values to use. anchor : double The location anchor ``M`` (the median if not input). c : double The tuning constant. mad : double The unscaled median absolute deviation about the median. Returns ------- result : double The biweight location. """ cdef Py_ssize_t i cdef double d, u, weight cdef double num = 0.0, den = 0.0 for i in range(lo, hi): d = s[i] - anchor u = d / (c * mad) if fabs(u) < 1.0: weight = 1.0 - u * u weight *= weight num += d * weight den += weight return anchor + num / den cdef inline double _biweight_midvar_range(double *s, Py_ssize_t lo, Py_ssize_t hi, double anchor, double c, double mad) noexcept nogil: """ Biweight midvariance of the ascending-sorted values ``s[lo:hi]``. The convention matches `astropy.stats.biweight_midvariance` with ``modify_sample_size=False``, a scalar location anchor (``M``), and a nonzero unscaled median absolute deviation (``mad``). The caller must handle the ``mad == 0`` case (astropy returns 0). If every value is rejected (``u**2 >= 1``), NaN is returned via the IEEE 0/0 division, matching astropy. Parameters ---------- s : double * The ascending-sorted values. lo, hi : Py_ssize_t The half-open index range of the values to use. anchor : double The location anchor ``M`` (the median if not input). c : double The tuning constant. mad : double The unscaled median absolute deviation about the median. Returns ------- result : double The biweight midvariance. """ cdef Py_ssize_t i, cnt = hi - lo cdef double d, u2, omu cdef double f1 = 0.0, f2 = 0.0 for i in range(lo, hi): d = s[i] - anchor u2 = d / (c * mad) u2 = u2 * u2 if u2 < 1.0: omu = 1.0 - u2 f1 += d * d * omu * omu * omu * omu f2 += omu * (1.0 - 5.0 * u2) return cnt * f1 / (f2 * f2) def batch_sigma_clip_stats(double[:, ::1] sorted_data, double sigma_lower, double sigma_upper, Py_ssize_t maxiters, int cenfunc_code, int stdfunc_code, bint do_clip, bint compute_mad, bint compute_biloc, double biloc_c, double biloc_m, bint compute_biscale, double biscale_c, double biscale_m): """ Compute fused sigma-clipped statistics for each row of a 2D array. For each row, the non-NaN values are sigma-clipped (see ``_sigma_clip_bounds``), and the mean, median, and population standard deviation of the surviving values are computed directly, without generating a clipped copy of the input. Optionally, the MAD standard deviation (`astropy.stats.mad_std`), the biweight location (`astropy.stats.biweight_location`), and the biweight scale (`astropy.stats.biweight_scale`) are also computed. Each row must be ascending-sorted with any NaN values placed last, as done by `numpy.sort`. Sorting is delegated to the caller, whose ``numpy.sort`` places the NaN values last for the large rows this function is designed for. The input array is not modified. The rows are typically the flattened pixels of the boxes of a regular grid (e.g., the `~photutils.background.Background2D` low-resolution mesh), with NaN values marking masked pixels. Infinite values are not removed and give nonfinite statistics, so the caller must replace them with NaN. Parameters ---------- sorted_data : 2D ndarray of float64 The row data, ascending-sorted along the last axis with NaN values last. NaN values are ignored. sigma_lower, sigma_upper : float The lower and upper clipping limits in units of the scale. maxiters : int The maximum number of clipping iterations, or a negative value to iterate until convergence. cenfunc_code, stdfunc_code : int The center function code (0 = median, 1 = mean, 2 = biweight location) and the scale function code (0 = std, 1 = mad_std, 2 = biweight scale). do_clip : bool Whether to apply sigma clipping. If False, the statistics are computed from all non-NaN values and the clipping parameters are ignored. compute_mad : bool Whether to compute the MAD standard deviation. If False, the returned ``madstd`` values are all NaN. compute_biloc : bool Whether to compute the biweight location (`astropy.stats.biweight_location`). If False, the returned ``biloc`` values are all NaN. biloc_c, biloc_m : float The biweight location tuning constant and scalar location anchor (``M``). A NaN ``biloc_m`` means no anchor was input (the median is used). Ignored if ``compute_biloc`` is False. compute_biscale : bool Whether to compute the biweight scale (`astropy.stats.biweight_scale`). If False, the returned ``biscale`` values are all NaN. biscale_c, biscale_m : float The biweight scale tuning constant and scalar location anchor (``M``). A NaN ``biscale_m`` means no anchor was input (the median is used). Ignored if ``compute_biscale`` is False. Returns ------- mean, median, std, madstd, biloc, biscale : 1D ndarray of float64 The per-row statistics of the surviving values. NaN for rows with no surviving values. n_kept : 1D ndarray of intp The per-row number of surviving values. """ cdef Py_ssize_t n_rows = sorted_data.shape[0] cdef Py_ssize_t n_cols = sorted_data.shape[1] mean_arr = np.full(n_rows, np.nan, dtype=np.float64) median_arr = np.full(n_rows, np.nan, dtype=np.float64) std_arr = np.full(n_rows, np.nan, dtype=np.float64) madstd_arr = np.full(n_rows, np.nan, dtype=np.float64) biloc_arr = np.full(n_rows, np.nan, dtype=np.float64) biscale_arr = np.full(n_rows, np.nan, dtype=np.float64) n_kept_arr = np.zeros(n_rows, dtype=np.intp) cdef double[::1] mean_out = mean_arr cdef double[::1] median_out = median_arr cdef double[::1] std_out = std_arr cdef double[::1] madstd_out = madstd_arr cdef double[::1] biloc_out = biloc_arr cdef double[::1] biscale_out = biscale_arr cdef Py_ssize_t[::1] n_kept_out = n_kept_arr if n_rows == 0 or n_cols == 0: return (mean_arr, median_arr, std_arr, madstd_arr, biloc_arr, biscale_arr, n_kept_arr) cdef bint need_mad = compute_mad or compute_biloc or compute_biscale cdef Py_ssize_t k, i, n, lo, hi, cnt cdef double v, minv, maxv, mu, ss, med, madk, anchor cdef double *s with nogil: for k in range(n_rows): # The finite values are s[0:n] because NaN values sort last n = n_cols while n > 0: v = sorted_data[k, n - 1] if v == v: # not NaN break n -= 1 if n == 0: continue s = &sorted_data[k, 0] lo = 0 hi = n if do_clip: _sigma_clip_bounds(s, n, sigma_lower, sigma_upper, maxiters, cenfunc_code, stdfunc_code, &minv, &maxv) # A value survives if not (v < minv) and not (v > maxv). # When the clip rejects every value, the bounds exclude # all of them and cnt becomes 0. while lo < n and s[lo] < minv: lo += 1 while hi > lo and s[hi - 1] > maxv: hi -= 1 cnt = hi - lo if cnt == 0: continue mu = 0.0 for i in range(lo, hi): mu += s[i] mu /= cnt ss = 0.0 for i in range(lo, hi): ss += (s[i] - mu) * (s[i] - mu) med = _median_sorted(&s[lo], cnt) mean_out[k] = mu std_out[k] = sqrt(ss / cnt) median_out[k] = med n_kept_out[k] = cnt if need_mad: # Unscaled MAD about the median of the survivors madk = _mad_sorted(&s[lo], cnt) if compute_mad: madstd_out[k] = madk * _MAD_STD_SCALE if compute_biloc: anchor = biloc_m if biloc_m == biloc_m else med if madk == 0.0: # astropy returns the anchor for zero MAD biloc_out[k] = anchor else: biloc_out[k] = _biweight_location_range( s, lo, hi, anchor, biloc_c, madk) if compute_biscale: anchor = biscale_m if biscale_m == biscale_m else med if madk == 0.0: # astropy returns zero variance for zero MAD biscale_out[k] = 0.0 else: biscale_out[k] = sqrt(_biweight_midvar_range( s, lo, hi, anchor, biscale_c, madk)) return (mean_arr, median_arr, std_arr, madstd_arr, biloc_arr, biscale_arr, n_kept_arr) astropy-photutils-24be529/photutils/aperture/_common.py000066400000000000000000000362321526452531300235010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Helpers shared by the aperture photometry and statistics classes. The `~photutils.aperture.AperturePhotometry` and `~photutils.aperture.ApertureStats` classes accept the same ``data``, ``error``, ``mask``, and ``wcs`` inputs and present their per-source results with the same scalar-collapse convention. Both also feed the same batch Cython drivers. The shared logic lives here so that the two code paths cannot silently diverge. """ import warnings from typing import NamedTuple import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from astropy.nddata import StdDevUncertainty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture._segmentation import (SEG_METHOD_CODES, batch_segmentation_image) __all__ = [] # Types of the public per-source attributes that are collapsed to a # scalar for a scalar instance (see ``collapse_scalar_value``). SCALAR_COLLAPSE_TYPES = (np.ndarray, SkyCoord, list, tuple) def unpack_nddata(data, error, mask, wcs): """ Extract the data, error, mask, and WCS from a `~astropy.nddata.NDData` object. A warning is emitted for each of the ``error``, ``mask``, and ``wcs`` keywords that was also input directly, because the `~astropy.nddata.NDData` attributes take precedence. Parameters ---------- data : `~astropy.nddata.NDData` The NDData object. error, mask, wcs : object The values input directly by the user. These are ignored and are used only to emit the warnings. Returns ------- data : `~numpy.ndarray` or `~astropy.units.Quantity` The NDData data array, as a Quantity if it has units. error : `~numpy.ndarray`, `~astropy.units.Quantity`, or `None` The standard-deviation uncertainty array, or `None` if the NDData uncertainty is not a `~astropy.nddata.StdDevUncertainty`. mask : `~numpy.ndarray` or `None` The NDData mask. wcs : WCS object or `None` The NDData 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 is ignored. Its value ' 'is obtained from the input NDData object.') warnings.warn(msg, AstropyUserWarning) mask = data.mask wcs = data.wcs error = None 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 def validate_array(array, name, *, ndim=2, shape=None): """ Validate the dimensionality and shape of an input array. Parameters ---------- array : array_like or `None` The array to validate. `None` is returned unchanged. For a mask, `numpy.ma.nomask` is converted to `None`. name : str The name of the array, used in the error messages. ndim : int, optional The required number of dimensions. shape : tuple of int or `None`, optional The required shape. If `None`, the shape is not checked. Returns ------- array : `~numpy.ndarray` or `None` The input array as an ndarray (subclasses are preserved), or `None`. """ 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 is not None and array.shape != shape: msg = f'data and {name} must have the same shape' raise ValueError(msg) return array def collapse_scalar_value(value): """ Collapse the leading length-1 (source) axis of a per-source value. Parameters ---------- value : object The per-source value. Returns ------- value : object The first element of ``value`` if it has a leading length-1 axis, otherwise ``value`` unchanged. """ try: if len(value) == 1: return value[0] except TypeError: # value has no len pass return value def validate_mask_method(method, subpixels, *, method_name='mask method'): """ Validate the aperture-mask method and subpixels keywords. Parameters ---------- method : {'exact', 'center', 'subpixel'} The aperture-mask method. subpixels : int The subsampling factor per axis used when ``method='subpixel'``. It is not validated for the other methods, which ignore it. method_name : str, optional The name of the method keyword, used in the error message. Raises ------ ValueError If ``method`` is not one of ``'exact'``, ``'center'``, or ``'subpixel'``, or if ``subpixels`` is not a strictly positive integer when ``method='subpixel'``. """ if method not in ('center', 'subpixel', 'exact'): msg = f'Invalid {method_name}: {method!r}' raise ValueError(msg) # bool is a subclass of int, so it is rejected explicitly to # prevent subpixels=True from being silently used as 1 if ((method == 'subpixel') and (isinstance(subpixels, bool) or not isinstance(subpixels, (int, np.integer)) or subpixels <= 0)): msg = 'subpixels must be a strictly positive integer' raise ValueError(msg) def batch_array_supported(array): """ Whether an array has a dtype supported by the batch Cython drivers. Parameters ---------- array : object The array to test. Returns ------- supported : bool `True` if the array is a plain `~numpy.ndarray` with a floating-point, integer, or boolean dtype of at most 8 bytes. """ return (type(array) is np.ndarray and array.dtype.kind in 'fiub' and array.dtype.itemsize <= 8) def batch_inputs_supported(data, error, mask): """ Whether the data, error, and mask arrays are supported by the batch Cython drivers. Parameters ---------- data : object The data array. error : object or `None` The error array. mask : object or `None` The boolean mask array. Returns ------- supported : bool `True` if all of the input arrays are supported, in which case the batch code path can be used. Otherwise `False`, and the caller must use the mask-based code path. """ if not batch_array_supported(data): return False if error is not None and not batch_array_supported(error): return False return not (mask is not None and (not isinstance(mask, np.ndarray) or mask.dtype != bool or mask.shape != data.shape)) def batch_image_dtype(*arrays): """ Return the common dtype in which the batch Cython drivers read the image arrays. The drivers read C-contiguous float32 or float64 images directly, converting each pixel value to double as it is read. That conversion is exact, so the results agree to within rounding for either dtype. All of the images passed to a driver must have the same dtype. Parameters ---------- *arrays : `~numpy.ndarray` or `None` The image arrays. `None` values are ignored. Returns ------- dtype : type `numpy.float32` if all of the input arrays are floating-point arrays of at most 4 bytes per value (e.g., float32, big-endian float32 data read from a FITS file, or float16), whose values are exactly representable in float32. Otherwise `numpy.float64`. """ if all(array.dtype.kind == 'f' and array.dtype.itemsize <= 4 for array in arrays if array is not None): return np.float32 return np.float64 def batch_image_arrays(*arrays): """ Return the image arrays in the form read by the batch Cython drivers (see `batch_image_dtype`). Parameters ---------- *arrays : `~numpy.ndarray` or `None` The image arrays. `None` values are passed through. Returns ------- result : list of `~numpy.ndarray` or `None` The C-contiguous arrays in their common dtype. Arrays that are already C-contiguous with that dtype are returned without a copy. """ dtype = batch_image_dtype(*arrays) return [None if array is None else np.ascontiguousarray(array, dtype=dtype) for array in arrays] def batch_mask_plane(mask): """ Build the uint8 mask plane used by the batch Cython kernels. Bit 1 (value 1) marks input-masked pixels. Nonfinite ``data`` pixels are not marked in the plane. The kernels test them per pixel (their ``mask_nonfinite`` argument), so no full-image pass is needed. Parameters ---------- mask : `~numpy.ndarray` (bool) or `None` The input mask. Returns ------- plane : `~numpy.ndarray` (uint8) or `None` The C-contiguous mask plane, or `None` if there is no input mask. The plane is a view of a C-contiguous input mask (no copy is made). Notes ----- The plane is a byte view, so the bytes of the input mask are not normalized to 0 and 1. An ill-formed boolean array whose storage holds other byte values (e.g., a uint8 array viewed as bool) excludes the same pixels, but the kernels interpret its bytes as plane bits, so such a pixel can be counted as nonfinite (bit 2) rather than as masked (bit 1). Boolean arrays created by NumPy always hold 0 or 1. """ if mask is None: return None # A boolean array stores one byte (0 or 1) per element, so it can # be viewed as uint8 without a copy. return np.ascontiguousarray(mask).view(np.uint8) def batch_segmentation_arrays(segmentation, labels, mask_method): """ Build the segmentation inputs for the batch Cython kernels. Parameters ---------- segmentation : `~numpy.ndarray` or `None` The validated segmentation array. labels : `~numpy.ndarray` or `None` The per-aperture source labels. mask_method : {'none', 'mask', 'source_only', 'background_only', \ 'correct'} The segmentation masking method. Returns ------- segmentation, labels : `~numpy.ndarray` or `None` The C-contiguous segmentation array (see `batch_segmentation_image`) and intp source labels, or `None` if no segmentation masking is performed. method_code : int The segmentation method code (0 disables masking). """ if segmentation is None or mask_method == 'none': return None, None, 0 return (batch_segmentation_image(segmentation), np.ascontiguousarray(labels, dtype=np.intp), SEG_METHOD_CODES[mask_method]) class BatchInputs(NamedTuple): """ The inputs of the batch Cython drivers for one aperture and image (see `batch_driver_inputs`). The field names match the corresponding parameters of `~photutils.aperture._batch_photometry.batch_aperture_sums`. """ data: np.ndarray """The C-contiguous data array.""" error: np.ndarray | None """The C-contiguous error array, with the same dtype as the data.""" mask: np.ndarray | None """The uint8 mask plane (see `batch_mask_plane`).""" positions: np.ndarray """The ``(n_sources, 2)`` float64 aperture positions.""" shape_code: int """The aperture shape code.""" params: np.ndarray """The float64 aperture shape parameters.""" ext_x: float """The half-extent of the aperture bounding box along x.""" ext_y: float """The half-extent of the aperture bounding box along y.""" off_x: float """The x offset of the bounding-box center from the position.""" off_y: float """The y offset of the bounding-box center from the position.""" use_exact: int """Whether the exact overlap method is used (1) or not (0).""" subpixels: int """The number of subpixels of the subpixel overlap method.""" segmentation: np.ndarray | None """The C-contiguous segmentation array.""" labels: np.ndarray | None """The intp source label of each aperture.""" seg_method: int """The segmentation method code (0 disables masking).""" def batch_driver_inputs(aperture, data, *, error, mask, method, subpixels, segmentation, labels, mask_method): """ Build the inputs of the batch Cython drivers for a pixel aperture. Parameters ---------- aperture : `~photutils.aperture.PixelAperture` The pixel aperture. data : `~numpy.ndarray` The 2D data array, with any units already stripped. error : `~numpy.ndarray` or `None` The 2D error array, with any units already stripped. mask : `~numpy.ndarray` (bool) or `None` The input mask. method : {'exact', 'center', 'subpixel'} The aperture mask method. subpixels : int The number of subpixels for the ``'subpixel'`` method. segmentation : `~numpy.ndarray` or `None` The validated segmentation array. labels : `~numpy.ndarray` or `None` The per-aperture source labels. mask_method : {'none', 'mask', 'source_only', 'background_only', \ 'correct'} The segmentation masking method. Returns ------- inputs : `BatchInputs` or `None` The driver inputs, or `None` if the batch drivers do not support this aperture or these input arrays (see `batch_inputs_supported`). In that case the caller must use the mask-based code path. """ # Use the batch drivers only if the aperture's own class opted # in via the _enable_batch_photometry decorator. Undecorated # subclasses may override other behavior (e.g., to_mask) that the # batch drivers would not honor, so they use the mask-based code # path. if type(aperture)._batch_photometry_class is not type(aperture): return None spec = aperture._batch_shape_params() if spec is None: return None if not batch_inputs_supported(data, error, mask): return None seg_arr, labels_arr, seg_code = batch_segmentation_arrays( segmentation, labels, mask_method) use_exact, subpixels = aperture._translate_mask_method(method, subpixels) shape_code, params = spec ext_x, ext_y = aperture._xy_extents off_x, off_y = aperture._xy_bbox_offset data, error = batch_image_arrays(data, error) positions = np.ascontiguousarray(aperture._positions, dtype=np.float64) params = np.array(params, dtype=np.float64) # The fields are passed by position, in the `BatchInputs` field # order. return BatchInputs(data, error, batch_mask_plane(mask), positions, shape_code, params, float(ext_x), float(ext_y), float(off_x), float(off_y), use_exact, subpixels, seg_arr, labels_arr, seg_code) astropy-photutils-24be529/photutils/aperture/_segmentation.py000066400000000000000000000277361526452531300247170ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for segmentation-based masking of aperture photometry. These helpers validate the ``segmentation_image``, ``labels``, and ``mask_method`` keywords shared by `~photutils.aperture.PixelAperture._photometry`, `~photutils.aperture.AperturePhotometry`, and `~photutils.aperture.ApertureStats`. They also apply the local masking or symmetric-correction behavior to per-aperture cutouts. """ import numpy as np from photutils.utils._round import round_half_away __all__ = [] # The valid ``mask_method`` values and their integer codes used # by the batch Cython driver (see ``_batch_photometry.pyx``). MASK_METHODS = ('none', 'mask', 'source_only', 'background_only', 'correct') SEG_METHOD_CODES = {'none': 0, 'mask': 1, 'source_only': 2, 'background_only': 3, 'correct': 4} def batch_segmentation_image(segmentation): """ Return the segmentation array in the form read by the batch Cython drivers. Parameters ---------- segmentation : `~numpy.ndarray` The integer segmentation array. Returns ------- result : `~numpy.ndarray` The C-contiguous array. The drivers read 32-bit (C ``int``) and `numpy.intp` arrays directly, so those are returned without a copy if they are already C-contiguous. Nonnative byte-order arrays of those dtypes (e.g., big-endian int32 data read from a FITS file) are converted to native byte order. Any other dtype is converted to `numpy.intp`. """ dtype = segmentation.dtype.newbyteorder('=') if dtype not in (np.dtype(np.intc), np.dtype(np.intp)): dtype = np.intp return np.ascontiguousarray(segmentation, dtype=dtype) def _validate_labels_shape(labels, n_positions): """ Validate that the labels are 1D with one entry per aperture position. Parameters ---------- labels : int or array_like The source label(s) associated with the aperture positions. n_positions : int The number of aperture positions. Returns ------- labels : 1D `~numpy.ndarray` The labels as a 1D array. """ labels = np.atleast_1d(labels) if labels.ndim != 1: msg = 'labels must be a 1D array' raise ValueError(msg) if labels.shape[0] != n_positions: msg = ('labels must have the same length as the number of ' 'aperture positions') raise ValueError(msg) return labels def process_segmentation_inputs(segmentation_image, labels, mask_method, positions, data_shape): """ Validate the segmentation-masking inputs and resolve the per-aperture source labels. Parameters ---------- segmentation_image : `~photutils.segmentation.SegmentationImage` \ or `None` The segmentation image, where background pixels are zero and sources have positive integer labels. A `~photutils.segmentation.SegmentationImage` is required (rather than a plain array) because its cached labels make the per-call validation of ``labels`` inexpensive. labels : int, 1D array_like, or `None` The source label(s) associated with the aperture ``positions``. ``labels`` is required (must not be `None`) when ``segmentation_image`` is input and ``mask_method`` is ``'mask'``, ``'source_only'``, or ``'correct'``. Each nonzero label must be present in the ``segmentation_image``. A label of 0 disables the masking for that aperture. ``labels`` is not used when ``mask_method`` is ``'none'`` or ``'background_only'``. In that case the labels need not be present in the ``segmentation_image``, but if input they must still have one entry per aperture position. mask_method : {'none', 'mask', 'source_only', 'background_only', \ 'correct'} The segmentation masking method. positions : 2D array_like The ``(x, y)`` aperture positions with shape ``(n_positions, 2)``. data_shape : tuple of int The shape of the data array. Returns ------- segmentation : 2D `~numpy.ndarray` (int32 or intp) or `None` The validated segmentation array, or `None` if ``mask_method`` is ``'none'``. labels : 1D `~numpy.ndarray` (intp) or `None` The resolved per-aperture source labels, or `None` if ``mask_method`` is ``'none'``. For ``'background_only'``, which does not use the labels, an array of zeros. """ if mask_method not in MASK_METHODS: msg = f'mask_method must be one of {MASK_METHODS}' raise ValueError(msg) positions = np.atleast_2d(positions) n_positions = positions.shape[0] if mask_method == 'none': # The labels are not used, but their shape is still validated # because the input labels are stored and sliced per aperture. if labels is not None: _validate_labels_shape(labels, n_positions) return None, None if segmentation_image is None: msg = ('segmentation_image must be input when mask_method ' "is not 'none'") raise ValueError(msg) # Local import to avoid a circular import with photutils.segmentation from photutils.segmentation import SegmentationImage if not isinstance(segmentation_image, SegmentationImage): msg = 'segmentation_image must be a SegmentationImage' raise TypeError(msg) segm = segmentation_image.data if segm.shape != tuple(data_shape): msg = 'segmentation_image must have the same shape as the data' raise ValueError(msg) segm = batch_segmentation_image(segm) if labels is None: if mask_method == 'background_only': # The labels are not used, but the batch kernels require a # per-source labels array. return segm, np.zeros(n_positions, dtype=np.intp) msg = ('labels must be input when segmentation_image is input ' "and mask_method is not 'none'") raise ValueError(msg) labels = _validate_labels_shape(labels, n_positions) labels = np.ascontiguousarray(labels, dtype=np.intp) if mask_method == 'background_only': # The labels are not used, so they need not be present in the # segmentation image. Their shape is still validated above # because the input labels are stored and sliced per aperture. return segm, np.zeros(n_positions, dtype=np.intp) # Each nonzero label must be present in the segmentation image. # Otherwise, every labeled pixel would silently be treated as a # neighbor of the target source. The SegmentationImage labels are # cached, so this check is inexpensive. nonzero = labels[labels != 0] present = np.isin(nonzero, segmentation_image.labels) bad_labels = np.unique(nonzero[~present]) if bad_labels.size > 0: msg = (f'labels {bad_labels.tolist()} are not present in the ' 'segmentation_image') raise ValueError(msg) return segm, labels def make_segmentation_exclusion(mask_method, segmentation_cutout, label, *, data=None, error=None, base_mask=None, cutout_xycen=None): """ Build the segmentation-based pixel exclusion mask for a single aperture cutout, optionally applying the symmetric ``'correct'`` replacement to the ``data`` and ``error`` cutouts. Parameters ---------- mask_method : {'none', 'mask', 'source_only', 'background_only', \ 'correct'} The segmentation masking method. segmentation_cutout : 2D `~numpy.ndarray` The segmentation cutout for the aperture, aligned with ``data``. label : int The target source label for this aperture. If ``label`` is 0, masking is disabled and no pixels are excluded. The label is ignored by the ``'background_only'`` method, which has no target source. data : 2D `~numpy.ndarray`, optional The data cutout, required for the ``'correct'`` method. error : 2D `~numpy.ndarray`, optional The error cutout, mirror-corrected alongside ``data`` for the ``'correct'`` method. base_mask : 2D bool `~numpy.ndarray`, optional Pixels that are already masked (e.g., the global ``mask`` or nonfinite values). Used by the ``'correct'`` method to avoid mirroring from masked pixels. cutout_xycen : tuple of float, optional The ``(x, y)`` aperture center relative to the cutout origin, required for the ``'correct'`` method. Returns ------- data : 2D `~numpy.ndarray` The (possibly mirror-corrected) data cutout. error : 2D `~numpy.ndarray` or `None` The (possibly mirror-corrected) error cutout. exclude : 2D bool `~numpy.ndarray` A boolean mask where `True` indicates a pixel to exclude from the photometry. affected : 2D bool `~numpy.ndarray` A boolean mask where `True` indicates a pixel excluded, replaced, or excluded-instead-of-replaced due to a neighboring source (i.e., a labeled pixel that is not the target source). Background pixels excluded by the ``'source_only'`` method are not marked. For the ``'background_only'`` method, every labeled pixel is a neighbor pixel. For the ``'correct'`` method, ``exclude`` marks exactly the neighbor pixels that could not be corrected. ``exclude`` and ``affected`` may be the same array object, so neither must be modified in place. """ exclude = np.zeros(segmentation_cutout.shape, dtype=bool) # The 'none' and 'background_only' methods return one array as both # the exclude and affected masks. Callers must not modify either # mask in place. if mask_method == 'none': return data, error, exclude, exclude if mask_method == 'background_only': labeled = segmentation_cutout > 0 return data, error, labeled, labeled if label == 0: return data, error, exclude, exclude neighbor = ((segmentation_cutout > 0) & (segmentation_cutout != label)) if mask_method == 'mask': return data, error, neighbor, neighbor if mask_method == 'source_only': exclude = segmentation_cutout != label return data, error, exclude, neighbor # The remaining method is the symmetric 'correct' replacement. return _correct_neighbors(segmentation_cutout, neighbor, data, error, base_mask, cutout_xycen) def _correct_neighbors(segmentation_cutout, neighbor, data, error, base_mask, cutout_xycen): """ Replace neighbor-source pixels with their values mirrored across the aperture center. A neighbor pixel whose mirror pixel is unavailable (outside the cutout, itself a neighbor, or in ``base_mask``) is excluded from the photometry instead of being replaced. """ ny, nx = segmentation_cutout.shape exclude = np.zeros(segmentation_cutout.shape, dtype=bool) data = data.copy() if error is not None: error = error.copy() cx = int(round_half_away(cutout_xycen[0])) cy = int(round_half_away(cutout_xycen[1])) yidx, xidx = np.nonzero(neighbor) xmirror = 2 * cx - xidx ymirror = 2 * cy - yidx out_of_bounds = ((xmirror < 0) | (ymirror < 0) | (xmirror >= nx) | (ymirror >= ny)) exclude[yidx[out_of_bounds], xidx[out_of_bounds]] = True keep = ~out_of_bounds yidx = yidx[keep] xidx = xidx[keep] xmirror = xmirror[keep] ymirror = ymirror[keep] bad_mirror = neighbor[ymirror, xmirror] if base_mask is not None: bad_mirror |= base_mask[ymirror, xmirror] exclude[yidx[bad_mirror], xidx[bad_mirror]] = True good = ~bad_mirror gy = yidx[good] gx = xidx[good] gmy = ymirror[good] gmx = xmirror[good] data[gy, gx] = data[gmy, gmx] if error is not None: error[gy, gx] = error[gmy, gmx] return data, error, exclude, neighbor astropy-photutils-24be529/photutils/aperture/attributes.py000066400000000000000000000201421526452531300242310ustar00rootroot00000000000000# 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', 'PositiveScalarAngle', '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) self._validate_ordered_pairs(instance, value) # No need to reset if not already in the instance dict if self.name in instance.__dict__: self._reset_cached_properties(instance) instance.__dict__[self.name] = value def _validate_ordered_pairs(self, instance, value): """ Validate the ordering invariants between related parameters declared by the owner class in ``_ordered_pairs``. ``_ordered_pairs`` is a tuple of ``(inner, outer)`` parameter name pairs, where the inner value must be strictly less than the outer value (e.g., an annulus inner and outer radius). The check runs whenever either parameter of a pair is assigned, comparing the new value against the other parameter's current value. It is skipped while the other parameter is not yet set (e.g., partway through ``__init__``). The check runs before the new value is stored, so a failed assignment leaves the instance unchanged. """ for inner, outer in getattr(instance, '_ordered_pairs', ()): if self.name == inner: other = instance.__dict__.get(outer) if other is not None and not other > value: msg = f'{outer!r} must be greater than {inner!r}' raise ValueError(msg) elif self.name == outer: other = instance.__dict__.get(inner) if other is not None and not value > other: msg = f'{outer!r} must be greater than {inner!r}' raise ValueError(msg) def _reset_cached_properties(self, instance): # Reset cached properties (if they exist) for aperture parameter # changes try: for key in instance._cached_properties: 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 float `~numpy.ndarray` of shape ``(2,)`` for a single position or ``(N, 2)`` for multiple positions. """ 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_cached_properties(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 nonfinite ' '(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): msg = f'{self.name!r} must be a positive scalar' if not np.isscalar(value): raise ValueError(msg) try: # NaN compares False, so it is also rejected here positive = value > 0 except TypeError: # Nonnumeric scalars (e.g., strings) do not support # comparison with 0 raise TypeError(msg) from None if not positive: 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(ScalarAngle): """ Check that value is a strictly positive (> 0) scalar angle, either as a `~astropy.coordinates.Angle` or `~astropy.units.Quantity` with angular units. """ def _validate(self, value): super()._validate(value) # NaN compares False, so it is also rejected here if not 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_cached_properties(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-24be529/photutils/aperture/bounding_box.py000066400000000000000000000315511526452531300245260ustar00rootroot00000000000000# 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), the upper limits are one larger than the index of the last pixel that the rectangle touches. 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): # NotImplemented lets Python fall back to the reflected # comparison and then to identity, so that comparing to a # non-BoundingBox returns False instead of raising. This keeps # ``!=``, ``in``, and ``list.remove`` usable. if not isinstance(other, BoundingBox): return NotImplemented return ((self.ixmin == other.ixmin) and (self.ixmax == other.ixmax) and (self.iymin == other.iymin) and (self.iymax == other.iymax)) def __hash__(self): # Return a hash value for the BoundingBox using its four # defining indices. return hash((self.ixmin, self.ixmax, self.iymin, self.iymax)) def __or__(self, other): # NotImplemented (instead of the TypeError raised by ``union``) # lets Python try the reflected operation and raise its standard # unsupported-operand TypeError. if not isinstance(other, BoundingBox): return NotImplemented return self.union(other) def __and__(self, other): # NotImplemented (instead of the TypeError raised by # ``intersection``) lets Python try the reflected operation and # raise its standard unsupported-operand TypeError. if not isinstance(other, BoundingBox): return NotImplemented 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 bounding box, defined as the ``(xmin, xmax, ymin, ymax)`` edges 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 the smallest `BoundingBox` that contains both this `BoundingBox` and another `BoundingBox`. Parameters ---------- other : `BoundingBox` The `BoundingBox` to join with this one. Returns ------- result : `BoundingBox` The smallest `BoundingBox` that contains both the input `BoundingBox` and 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` or `None` A `BoundingBox` representing the intersection of the input `BoundingBox` with this one. `None` is returned if the two bounding boxes do not overlap. """ 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-24be529/photutils/aperture/circle.py000066400000000000000000000642511526452531300233150ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Circular and circular-annulus apertures in both pixel and sky coordinates. """ import math from functools import cached_property import astropy.units as u import numpy as np from astropy.coordinates import Angle from photutils.aperture._batch_photometry import (SHAPE_CIRCLE, SHAPE_CIRCULAR_ANNULUS) from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, SkyCoordPositions) from photutils.aperture.core import (PixelAperture, SkyAperture, _enable_batch_photometry, _update_method_subpixels_docstring) from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.mask import ApertureMask from photutils.aperture.polygon import PolygonAperture, SkyPolygonAperture from photutils.geometry import circular_overlap_grid from photutils.utils._deprecation import deprecated from photutils.utils._wcs_helpers import (pixel_to_sky_mean_scale, pixel_to_sky_svd_scales, sky_to_pixel_mean_scale, sky_to_pixel_svd_scales) __all__ = [ 'CircularAnnulus', 'CircularAperture', 'CircularMaskMixin', 'SkyCircularAnnulus', 'SkyCircularAperture', ] def _circular_polygon_offsets(r, n_vertices): """ Compute the vertex offsets that approximate a circle of radius ``r`` using ``n_vertices`` equally spaced vertices. ``r`` may be a plain number (pixel offsets) or a `~astropy.units.Quantity` (angular offsets). The returned offsets carry the same type. Parameters ---------- r : float or `~astropy.units.Quantity` The radius of the circle. n_vertices : int The number of polygon vertices used to approximate the circle. Returns ------- offsets : 2D `~numpy.ndarray` The vertex offsets that approximate the circle. The shape is ``(n_vertices, 2)``, where the second axis corresponds to the ``(x, y)`` offsets. The offsets are in the same units as the input ``r``. """ n_vertices = int(n_vertices) if n_vertices < 3: msg = f'n_vertices must be at least 3, got {n_vertices}' raise ValueError(msg) theta = np.linspace(0.0, 2.0 * np.pi, n_vertices, endpoint=False) return np.column_stack([np.cos(theta), np.sin(theta)]) * r @deprecated('3.0', until='3.2') class CircularMaskMixin: # pragma: no cover """ Mixin class to create masks for circular and circular-annulus aperture objects. """ @_update_method_subpixels_docstring def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- 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 @_enable_batch_photometry 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 not positive. 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 @cached_property def _xy_extents(self): return self.r, self.r def _batch_shape_params(self): return SHAPE_CIRCLE, (self.r,) @cached_property 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 @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. """ return circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.r, use_exact, subpixels) def to_sky(self, wcs, *, as_ellipse=False): """ 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`). as_ellipse : bool, optional If `True`, return the image of the circle under the local linear WCS at the reference position, which is an ellipse whenever the pixels are not square or the WCS is sheared. If `False` (the default), return a circle whose radius is scaled by the isotropic mean pixel scale. Returns ------- aperture : `SkyCircularAperture` or `SkyEllipticalAperture` The sky aperture. A `SkyEllipticalAperture` is returned if ``as_ellipse`` is `True`. 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] pixcoord = (float(first_pos[0]), float(first_pos[1])) if as_ellipse: scale_major, scale_minor, theta = pixel_to_sky_svd_scales( wcs, pixcoord) a = Angle(self.r * scale_major, 'arcsec') b = Angle(self.r * scale_minor, 'arcsec') return SkyEllipticalAperture(positions=positions, a=a, b=b, theta=theta) mean_scale = pixel_to_sky_mean_scale(wcs, pixcoord) r = Angle(self.r * mean_scale, 'arcsec') return SkyCircularAperture(positions=positions, r=r) def to_polygon(self, *, n_vertices=100): """ Return a `~photutils.aperture.PolygonAperture` that approximates this circular aperture. Parameters ---------- n_vertices : int, optional The number of polygon vertices used to approximate the circle. Must be at least 3. Default is 100. Returns ------- aperture : `~photutils.aperture.PolygonAperture` A polygon aperture that approximates the circle. """ offsets = _circular_polygon_offsets(self.r, n_vertices) return PolygonAperture._from_simple_offsets(self.positions, offsets) @_enable_batch_photometry 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 the inner radius (``r_in``) is not less than the outer radius (``r_out``). ValueError : `ValueError` If either radius (``r_in`` or ``r_out``) is not positive. 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') _ordered_pairs = (('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): self.positions = positions self.r_in = r_in self.r_out = r_out @cached_property def _xy_extents(self): return self.r_out, self.r_out def _batch_shape_params(self): return SHAPE_CIRCULAR_ANNULUS, (self.r_in, self.r_out) @cached_property 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 @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. """ 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) # Clip tiny negative values arising from floating-point noise in # the subtraction. np.maximum(overlap, 0.0, out=overlap) return overlap def to_sky(self, wcs, *, as_ellipse=False): """ 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`). as_ellipse : bool, optional If `True`, return the image of the circular annulus under the local linear WCS at the reference position, which is an elliptical annulus whenever the pixels are not square or the WCS is sheared. If `False` (the default), return a circular annulus whose inner and outer radii are scaled by the isotropic mean pixel scale. Returns ------- aperture : `SkyCircularAnnulus` or `SkyEllipticalAnnulus` The sky aperture. A `SkyEllipticalAnnulus` is returned if ``as_ellipse`` is `True`. 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] pixcoord = (float(first_pos[0]), float(first_pos[1])) if as_ellipse: scale_major, scale_minor, theta = pixel_to_sky_svd_scales( wcs, pixcoord) return SkyEllipticalAnnulus( positions=positions, a_in=Angle(self.r_in * scale_major, 'arcsec'), a_out=Angle(self.r_out * scale_major, 'arcsec'), b_out=Angle(self.r_out * scale_minor, 'arcsec'), b_in=Angle(self.r_in * scale_minor, 'arcsec'), theta=theta) mean_scale = pixel_to_sky_mean_scale(wcs, pixcoord) 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. Raises ------ TypeError : `TypeError` If the input radius, ``r``, is not a `~astropy.units.Quantity`. ValueError : `ValueError` If the input radius, ``r``, is not positive. 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, *, as_ellipse=False): """ 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`). as_ellipse : bool, optional If `True`, return the image of the circle under the local linear WCS at the reference position, which is an ellipse whenever the pixels are not square or the WCS is sheared. If `False` (the default), return a circle whose radius is scaled by the isotropic mean pixel scale. Returns ------- aperture : `CircularAperture` or `EllipticalAperture` The pixel aperture. An `EllipticalAperture` is returned if ``as_ellipse`` is `True`. 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] first_pixcoord = tuple(np.atleast_2d(positions)[0]) r_arcsec = self.r.to_value(u.arcsec) if as_ellipse: _, scale_major, scale_minor, theta = sky_to_pixel_svd_scales( wcs, skypos, pixcoord=first_pixcoord) return EllipticalAperture(positions=positions, a=r_arcsec * scale_major, b=r_arcsec * scale_minor, theta=theta) _, mean_scale = sky_to_pixel_mean_scale(wcs, skypos, pixcoord=first_pixcoord) return CircularAperture(positions=positions, r=r_arcsec * mean_scale) def to_polygon(self, *, n_vertices=100): """ Return a `~photutils.aperture.SkyPolygonAperture` that approximates this circular aperture. Parameters ---------- n_vertices : int, optional The number of polygon vertices used to approximate the circle. Must be at least 3. Default is 100. Returns ------- aperture : `~photutils.aperture.SkyPolygonAperture` A sky polygon aperture that approximates the circle. """ offsets = _circular_polygon_offsets(self.r, n_vertices) return SkyPolygonAperture._from_simple_offsets(self.positions, offsets) 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. Raises ------ TypeError : `TypeError` If either radius (``r_in`` or ``r_out``) is not a `~astropy.units.Quantity`. ValueError : `ValueError` If the inner radius (``r_in``) is not less than the outer radius (``r_out``). ValueError : `ValueError` If either radius (``r_in`` or ``r_out``) is not positive. 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') _ordered_pairs = (('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): self.positions = positions self.r_in = r_in self.r_out = r_out def to_pixel(self, wcs, *, as_ellipse=False): """ 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`). as_ellipse : bool, optional If `True`, return the image of the circular annulus under the local linear WCS at the reference position, which is an elliptical annulus whenever the pixels are not square or the WCS is sheared. If `False` (the default), return a circular annulus whose inner and outer radii are scaled by the isotropic mean pixel scale. Returns ------- aperture : `CircularAnnulus` or `EllipticalAnnulus` The pixel aperture. An `EllipticalAnnulus` is returned if ``as_ellipse`` is `True`. 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] first_pixcoord = tuple(np.atleast_2d(positions)[0]) r_in_arcsec = self.r_in.to_value(u.arcsec) r_out_arcsec = self.r_out.to_value(u.arcsec) if as_ellipse: _, scale_major, scale_minor, theta = sky_to_pixel_svd_scales( wcs, skypos, pixcoord=first_pixcoord) return EllipticalAnnulus(positions=positions, a_in=r_in_arcsec * scale_major, a_out=r_out_arcsec * scale_major, b_out=r_out_arcsec * scale_minor, b_in=r_in_arcsec * scale_minor, theta=theta) _, mean_scale = sky_to_pixel_mean_scale(wcs, skypos, pixcoord=first_pixcoord) r_in = r_in_arcsec * mean_scale r_out = r_out_arcsec * mean_scale return CircularAnnulus(positions=positions, r_in=r_in, r_out=r_out) astropy-photutils-24be529/photutils/aperture/core.py000066400000000000000000001613401526452531300230010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Base aperture classes. """ import abc import inspect import re import textwrap import warnings from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from dataclasses import dataclass from functools import cached_property import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from photutils.aperture._batch_photometry import (FLAG_COL_BBOX_CLIPPED, FLAG_COL_MASKED, FLAG_COL_N_PIXELS, FLAG_COL_NONFINITE_DATA, FLAG_COL_NONFINITE_ERROR, FLAG_COL_SEG, FLAG_COL_UNCORRECTED, FLAG_COL_VALID, N_FLAG_COLS, batch_aperture_sums) from photutils.aperture._common import (batch_driver_inputs, validate_mask_method) from photutils.aperture._segmentation import (make_segmentation_exclusion, process_segmentation_inputs) from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.flags import APERTURE_FLAGS, _counts_to_flag_bits from photutils.aperture.mask import ApertureMask from photutils.utils._deprecation import (deprecated, deprecated_positional_kwargs) from photutils.utils._flags import define_flag_docstring __all__ = ['Aperture', 'PixelAperture', 'SkyAperture'] # Canonical descriptions of the ``method`` and ``subpixels`` parameters # shared by many aperture and profile docstrings. The text is stored # without leading indentation and is reindented to match the # placeholder by ``_update_method_subpixels_docstring``. _METHOD_INTRO = """\ method : {'exact', 'center', 'subpixel'}, optional The method used to determine the pixel weights (the fraction of the pixel area covered by the aperture):""" _METHOD_BULLETS = """\ * ``'exact'`` (default): Calculates the exact geometric overlap area. Weights are continuous in the range [0, 1]. * ``'center'``: Binary weighting based on the pixel center. Weights are either 0 or 1. A pixel is included only if its center lies strictly inside the aperture. Pixel centers lying exactly on the outer aperture boundary are excluded (weight 0). For an annulus, pixel centers lying exactly on the inner boundary are included (weight 1). * ``'subpixel'``: Approximates the overlap by averaging binary samples on a subgrid. The number of samples is set by the ``subpixels`` parameter. Weights are discrete in the range [0, 1]. A subpixel is included only if its center lies strictly inside the aperture. Subpixel centers lying exactly on the outer aperture boundary are excluded (weight 0). For an annulus, subpixel centers lying exactly on the inner boundary are included.""" _SUBPIXELS_DOC = """\ subpixels : int, optional The subsampling factor per axis used with the ``'subpixel'`` method. Each pixel is divided into a grid of ``subpixels**2`` subpixels to approximate the overlap. This parameter is ignored for other methods.""" _METHOD_SUBPIXELS_DOC = ( _METHOD_INTRO + '\n\n' + textwrap.indent(_METHOD_BULLETS, ' ') + '\n\n' + _SUBPIXELS_DOC) _SEGMENTATION_DOC = """\ segmentation_image : `~photutils.segmentation.SegmentationImage` or \ `None`, optional A segmentation image with the same shape as ``data``, where background pixels have a value of 0 and sources are labeled with positive integers. It is used to mask or correct neighboring sources within each aperture according to the ``mask_method`` keyword. This keyword is required if ``mask_method`` is not ``'none'`` and is ignored if ``mask_method`` is ``'none'`` (the default). The ``labels`` keyword must also be input for the ``'mask'``, ``'source_only'``, and ``'correct'`` methods so that the correct target source is used for each aperture. labels : int, 1D array_like, or `None`, optional The source label(s) in ``segmentation_image`` associated with the aperture position(s). ``labels`` is required if ``segmentation_image`` is input and ``mask_method`` is ``'mask'``, ``'source_only'``, or ``'correct'``. It is not used if ``mask_method`` is ``'none'`` or ``'background_only'``. ``labels`` must have the same length as the number of aperture positions, and each nonzero label must be present in the ``segmentation_image`` (unless ``mask_method`` is ``'none'`` or ``'background_only'``). A label of 0 disables the segmentation masking for that aperture. mask_method : {'none', 'mask', 'source_only', 'background_only', \ 'correct'}, optional The method used to handle neighboring sources within each aperture using the ``segmentation_image``: * ``'none'`` (default): The ``segmentation_image`` is ignored and all pixels within the aperture are included. * ``'mask'``: Pixels belonging to neighboring sources (i.e., labeled but not the target source) are excluded. * ``'source_only'``: Only pixels belonging to the target source are included. Both neighboring sources and background pixels are excluded. * ``'background_only'``: Only background pixels are included. Pixels belonging to any source are excluded, including the source at the aperture position (if any). This method is intended for estimating the local background, e.g., within an annulus aperture. The ``labels`` keyword is not used. * ``'correct'``: Pixels belonging to neighboring sources are replaced by the values of the pixels mirrored across the aperture center. If a mirror pixel is unavailable, the pixel is excluded.""" _COMPUTE_OVERLAP_DOC = """\ Parameters ---------- edges : tuple of float The ``(xmin, xmax, ymin, ymax)`` pixel edges of the aperture bounding box, after recentering the aperture at the origin. nx, ny : int The number of pixels in the x and y directions. use_exact : int Whether to compute the exact overlap area (1) or to use subpixel sampling (0). subpixels : int The number of subpixels in each dimension used when ``use_exact`` is 0. Returns ------- overlap : 2D `~numpy.ndarray` The fraction of each pixel that overlaps the aperture. The values are between 0 (no overlap) and 1 (full overlap).""" # Bullet list of the aperture quality flags, generated from the central # flag registry, used in docstrings via the ``flag_descriptions`` # placeholder. _FLAG_DESCRIPTIONS_DOC = '\n'.join(define_flag_docstring(APERTURE_FLAGS)) # Mapping of placeholder tags to their replacement text. Each tag must # appear alone on its own line in a docstring. The leading indentation # of the placeholder is applied to the inserted text. _DOC_PLACEHOLDERS = { 'method_subpixels_descriptions': _METHOD_SUBPIXELS_DOC, 'method_bullets': _METHOD_BULLETS, 'subpixels_description': _SUBPIXELS_DOC, 'segmentation_descriptions': _SEGMENTATION_DOC, 'flag_descriptions': _FLAG_DESCRIPTIONS_DOC, 'compute_overlap_docs': _COMPUTE_OVERLAP_DOC, } _DOC_PLACEHOLDER_RE = re.compile( r'^([ \t]*)<(' + '|'.join(_DOC_PLACEHOLDERS) + r')>[ \t]*$', re.MULTILINE) def _update_method_subpixels_docstring(obj): """ Decorator to insert standard ``method``, ``subpixels``, and related parameter descriptions into a docstring. The following placeholders are supported, each of which must appear alone on its own line. The leading indentation of the placeholder is applied to the inserted text, so the same source descriptions can be used at any docstring indentation level. * ```` : the full ``method`` and ``subpixels`` parameter descriptions. * ```` : only the ``'exact'``, ``'center'``, and ``'subpixel'`` bullet list, e.g., for a parameter that uses a custom name and introduction. * ```` : only the ``subpixels`` parameter description. * ```` : the ``segmentation_image``, ``labels``, and ``mask_method`` parameter descriptions. * ```` : the bullet list of aperture quality flags. * ```` : the Parameters and Returns sections of the ``_compute_overlap`` method. Parameters ---------- obj : function or type The function or class whose docstring will be updated. Returns ------- obj : function or type The input ``obj`` with its ``__doc__`` updated in place. """ docstring = obj.__doc__ if docstring is None: return obj def replace(match): indent = match.group(1) return textwrap.indent(_DOC_PLACEHOLDERS[match.group(2)], indent) obj.__doc__ = _DOC_PLACEHOLDER_RE.sub(replace, docstring) return obj def _validate_mask(mask, shape): """ Validate that ``mask`` is a boolean array with the given shape. Parameters ---------- mask : array_like (bool) or `None` The input mask. shape : tuple of int The required mask shape (i.e., the data shape). Returns ------- mask : `~numpy.ndarray` (bool) or `None` The validated mask as a boolean array, or `None` if ``mask`` is `None`. """ if mask is None: return None # asarray demotes ndarray subclasses (e.g., a boolean MaskedArray) # to a plain array of the underlying data, which the batch and # mask-based code paths handle identically. mask = np.asarray(mask) if mask.dtype != bool: msg = 'mask must be a boolean array' raise TypeError(msg) if mask.shape != shape: msg = 'mask and data must have the same shape' raise ValueError(msg) return mask def _enable_batch_photometry(cls): """ Class decorator that opts a `PixelAperture` subclass in to the batch Cython photometry driver. The decorator records the decorated class itself, and the batch driver is used only when an instance's own class was decorated (see ``PixelAperture._batch_photometry``). The opt-in therefore does not propagate to subclasses. An undecorated subclass may override behavior (e.g., ``to_mask``) that the batch driver would not honor, so it uses the mask-based code path unless it is explicitly decorated as well. A decorated class must provide the ``_batch_shape_params`` hook (which may be inherited). Parameters ---------- cls : type The aperture class to opt in. Returns ------- cls : type The input class, with the opt-in recorded. """ cls._batch_photometry_class = cls return cls @dataclass(frozen=True) class _ApertureResults: """ The results of `PixelAperture._photometry`. Attributes ---------- flux : `~numpy.ndarray` or `~astropy.units.Quantity` The sum of the data within each aperture. flux_err : `~numpy.ndarray` or `~astropy.units.Quantity` The uncertainty in the sum of the data within each aperture. area : `~astropy.units.Quantity` The total unmasked overlap area of each aperture (in pix**2). flags : `~numpy.ndarray` The bitwise quality flags for each aperture. See `~photutils.aperture.decode_aperture_flags` for decoding flag values. """ flux: np.ndarray flux_err: np.ndarray area: np.ndarray flags: np.ndarray 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) # Transplant the already-validated shape parameters directly # into the new instance instead of revalidating them through # __init__ (e.g., the polygon simple-polygon check is O(n^2) in # the number of vertices). Only the sliced positions are set # through their descriptor. newobj = self.__class__.__new__(self.__class__) newobj.positions = self.positions[index] for param in self._params: if param != 'positions': newobj.__dict__[param] = getattr(self, param) return newobj 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, ValueError): # A TypeError is raised by a SkyCoord comparison when # the two coordinates do not have equivalent frames. A # ValueError is raised by an array comparison when the two # parameters do not broadcast together (e.g., polygons with # different numbers of vertices). Both mean the apertures # are not equal, so return False instead of propagating the # exception. return False return True @property def _cached_properties(self): """ A list of all class cached properties (even in superclasses). The result depends only on the class, so it is computed once per class and cached (it is looked up on every aperture parameter reassignment). """ cls = self.__class__ cached = cls.__dict__.get('_cached_properties_cache') if cached is None: def is_cached_property(obj): return isinstance(obj, cached_property) cached = [i[0] for i in inspect.getmembers( cls, predicate=is_cached_property)] cls._cached_properties_cache = cached return cached 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`. """ @cached_property def shape(self): """ The shape of the instance. """ if isinstance(self.positions, SkyCoord): return self.positions.shape return self.positions.shape[:-1] @cached_property def isscalar(self): """ Whether the instance is scalar (i.e., a single position). """ return self.shape == () class _RotatableApertureMixin: """ Mixin class for apertures that have a rotation angle ``theta``. """ @cached_property def _theta_rad(self): """ The rotation angle in radians. This is a cached property so that it is invalidated together with the other cached properties when ``theta`` is reassigned. """ return self.theta.to_value(u.radian) class PixelAperture(Aperture): """ Abstract base class for apertures defined in pixel coordinates. """ # The class (if any) that opted in to the batch photometry driver # via the _enable_batch_photometry decorator. Instances use the # batch driver only when this is their own class _batch_photometry_class = None @cached_property 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): """ 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 the 'subpixel' method. Returns ------- use_exact : int Whether to use the exact method (1) or not (0). subpixels : int The number of subpixels per axis. It is 1 for the ``'exact'`` and ``'center'`` methods. """ validate_mask_method(method, subpixels) 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. """ @property def _xy_bbox_offset(self): """ The (x, y) offset of the bounding box center from ``positions``. This is zero for an aperture whose minimal bounding box is centered on ``positions``, which is the case for every aperture whose shape is symmetric about its position. """ return 0.0, 0.0 @cached_property def _positions(self): """ The aperture positions, always as a 2D ndarray. """ return np.atleast_2d(self.positions) @cached_property def _bbox_bounds(self): """ The integer bounding-box bounds, as an ``(n_positions, 4)`` array of ``(ixmin, ixmax, iymin, iymax)`` values. The bounds follow the same pixel-index arithmetic as `BoundingBox.from_float` (upper bounds exclusive), computed vectorized over the positions. Performance-sensitive consumers (e.g., `~photutils.aperture.ApertureStats`) use this array directly so that no per-position `BoundingBox` objects need to be created. """ x_delta, y_delta = self._xy_extents off_x, off_y = self._xy_bbox_offset xmin = self._positions[:, 0] + off_x - x_delta xmax = self._positions[:, 0] + off_x + x_delta ymin = self._positions[:, 1] + off_y - y_delta ymax = self._positions[:, 1] + off_y + y_delta bounds = np.empty((self._positions.shape[0], 4), dtype=int) bounds[:, 0] = np.floor(xmin + 0.5) bounds[:, 1] = np.ceil(xmax + 0.5) bounds[:, 2] = np.floor(ymin + 0.5) bounds[:, 3] = np.ceil(ymax + 0.5) return bounds @cached_property def _bbox(self): """ The minimal bounding box for the aperture, always as a list of `~photutils.aperture.BoundingBox` instances. """ return [BoundingBox(ixmin=ixmin, ixmax=ixmax, iymin=iymin, iymax=iymax) for ixmin, ixmax, iymin, iymax in self._bbox_bounds] @cached_property 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 @cached_property 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 """ @_update_method_subpixels_docstring def area_overlap(self, data, *, mask=None, method='exact', subpixels=5): # numpydoc ignore: PR01,PR02,PR04,PR07 """ 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 is used to compute the aperture sum for the same inputs, provided that no nonfinite data values are masked and no segmentation masking is applied. This method takes neither into account. Use the `area` attribute for the exact analytical area of the aperture shape. Parameters ---------- data : `~numpy.ndarray` 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. Returns ------- areas : float or `~numpy.ndarray` The area (in pixels**2) of overlap between the data and the aperture. A scalar aperture returns a float, otherwise a 1D array with one value per position. The value is NaN where the aperture does not overlap the data. See Also -------- area """ apermasks = self.to_mask(method=method, subpixels=subpixels) if self.isscalar: apermasks = (apermasks,) mask = _validate_mask(mask, data.shape) areas = [] for apermask in apermasks: slc_large, slc_small = apermask.get_overlap_slices(data.shape) # If the aperture does not overlap the data, return np.nan if slc_large is None: area = np.nan else: aper_weights = apermask.data[slc_small] if mask is not None: # Copy aper_weights before modifying it, because the # slice above is a view. aper_weights = aper_weights.copy() 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 @_update_method_subpixels_docstring @deprecated_positional_kwargs(since='3.0', until='4.0') def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- 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) 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 @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture for a single position. """ def _mask_photometry(self, data, *, error, mask, method, subpixels, segmentation=None, labels=None, mask_method='none', mask_nonfinite=False): """ Perform aperture photometry using per-source aperture masks. This is the fallback code path for apertures or inputs that are not supported by the batch Cython driver, including the segmentation masking methods for such apertures. All of the built-in apertures opt in to the batch driver, so this path is reached only for an unsupported input (see `~photutils.aperture._common.batch_inputs_supported`) or for an `Aperture` subclass that is not decorated with ``_enable_batch_photometry``. Parameters ---------- data : `~numpy.ndarray` The 2D array on which to perform photometry, with any units already stripped. error, mask, method, subpixels See `_photometry`. Any units must already be stripped from ``error``. segmentation, labels, mask_method : optional The validated segmentation array, per-aperture source labels, and masking method (see `~photutils.aperture._segmentation.process_segmentation_inputs`). mask_nonfinite : bool, optional Whether to mask nonfinite ``data`` values (matching `ApertureStats`) instead of leaving them in the computation, where they corrupt the sum (the 3.0.0 behavior, used by the legacy `aperture_photometry` function). In both cases, the nonfinite pixels are flagged as ``non_finite_data``. Returns ------- aperture_sums, aperture_sum_errs, areas : `~numpy.ndarray` The aperture sums, errors, and total unmasked overlap areas. flag_counts : `~numpy.ndarray` The per-source pixel counts for the quality flags, with one row per source and the columns given by the ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`, with semantics identical to the batch driver (the ``FLAG_COL_BBOX_CLIPPED`` column is a candidate indicator resolved by the caller, and the nonfinite columns are 0/1 indicators). overlap : `~numpy.ndarray` (bool) Whether the aperture bounding box overlaps the data. """ apermasks = self.to_mask(method=method, subpixels=subpixels) if self.isscalar: apermasks = (apermasks,) positions = np.atleast_2d(self.positions) n_src = len(apermasks) aperture_sums = [] aperture_sum_errs = [] areas = [] flag_counts = np.zeros((n_src, N_FLAG_COLS), dtype=np.intp) overlap = np.zeros(n_src, dtype=bool) with warnings.catch_warnings(): # Ignore multiplication with nonfinite data values warnings.simplefilter('ignore', RuntimeWarning) for idx, apermask in enumerate(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) areas.append(np.nan) continue overlap[idx] = True weighted = aper_weights > 0 w_in = np.count_nonzero(weighted) flag_counts[idx, FLAG_COL_N_PIXELS] = w_in flag_counts[idx, FLAG_COL_BBOX_CLIPPED] = ( aper_weights.shape != apermask.data.shape) if mask is not None: flag_counts[idx, FLAG_COL_MASKED] = np.count_nonzero( weighted & mask[slc_large]) data_cutout = data[slc_large] error_cutout = None if error is None else error[slc_large] # Nonfinite ``data`` masking (used by the class # path). Flag the contributing nonfinite pixels, then # exclude them *before* the segmentation handling so # the segmentation counts and the 'correct' mirror # replacement never use nonfinite pixels, matching the # batch kernels (``mask_nonfinite``) and `ApertureStats`. # When ``mask_nonfinite`` is `False` (the legacy # function), the nonfinite pixels are left in the # computation so they corrupt the sum. nonfinite_data = ~np.isfinite(data_cutout) if mask_nonfinite: flag_counts[idx, FLAG_COL_NONFINITE_DATA] = np.any( nonfinite_data & pixel_mask) pixel_mask = pixel_mask & ~nonfinite_data if segmentation is not None and mask_method != 'none': segm_cutout = segmentation[slc_large] base_mask = None if mask is None else mask[slc_large] if mask_nonfinite: # Fold the nonfinite pixels into the base mask # so that nonfinite mirror pixels are treated # as uncorrectable by the 'correct' method. base_mask = (nonfinite_data if base_mask is None else base_mask | nonfinite_data) cutout_xycen = (positions[idx, 0] - slc_large[1].start, positions[idx, 1] - slc_large[0].start) (data_cutout, error_cutout, exclude, affected) = make_segmentation_exclusion( mask_method, segm_cutout, labels[idx], data=data_cutout, error=error_cutout, base_mask=base_mask, cutout_xycen=cutout_xycen) flag_counts[idx, FLAG_COL_SEG] = np.count_nonzero( affected & pixel_mask) if mask_method == 'correct': # In 'correct' mode, the excluded pixels are # exactly the uncorrectable neighbor pixels flag_counts[idx, FLAG_COL_UNCORRECTED] = ( np.count_nonzero(exclude & pixel_mask)) pixel_mask = pixel_mask & ~exclude flag_counts[idx, FLAG_COL_VALID] = np.count_nonzero( pixel_mask) if not mask_nonfinite: flag_counts[idx, FLAG_COL_NONFINITE_DATA] = np.any( nonfinite_data & pixel_mask) if error is not None: flag_counts[idx, FLAG_COL_NONFINITE_ERROR] = np.any( ~np.isfinite(error_cutout) & pixel_mask) values = (data_cutout * aper_weights)[pixel_mask] aperture_sums.append(values.sum()) areas.append(aper_weights[pixel_mask].sum()) if error is not None: variance = (error_cutout**2 * aper_weights**2)[pixel_mask] aperture_sum_errs.append(np.sqrt(variance.sum())) else: aperture_sum_errs.append(np.nan) return (np.array(aperture_sums), np.array(aperture_sum_errs), np.array(areas), flag_counts, overlap) def _batch_shape_params(self): """ The aperture shape code and parameters for the batch Cython photometry driver. Returns ------- spec : tuple or `None` A ``(shape_code, params)`` tuple, where ``shape_code`` is one of the shape codes defined in `photutils.aperture._batch_photometry` and ``params`` is a tuple of the aperture shape parameters expected by `~photutils.aperture._batch_photometry.batch_aperture_sums` for that shape. `None` is returned if batch photometry is not supported for this aperture, in which case the slower mask-based code path is used. Notes ----- The batch driver is used only for classes decorated with ``_enable_batch_photometry`` (see `_batch_photometry`). The opt-in does not propagate to subclasses, so a subclass must itself be decorated to use the batch code path. """ return def _batch_photometry(self, data, *, error, mask, method, subpixels, segmentation=None, labels=None, mask_method='none', mask_nonfinite=False, n_threads=1): """ Perform aperture photometry using the batch Cython driver. The batch driver computes results that agree with the mask-based code path to within rounding, with identical flag counts, but without creating per-source mask arrays or making per-source Python calls. Parameters ---------- data : `~numpy.ndarray` The 2D array on which to perform photometry, with any units already stripped. error, mask, method, subpixels See `_photometry`. Any units must already be stripped from ``error``. segmentation, labels, mask_method : optional The validated segmentation array, per-aperture source labels, and masking method (see `~photutils.aperture._segmentation.process_segmentation_inputs`). mask_nonfinite : bool, optional Whether to mask nonfinite ``data`` values (matching `ApertureStats`) instead of leaving them in the computation, where they corrupt the sum (the 3.0.0 behavior, used by the legacy `aperture_photometry` function). In both cases, the nonfinite pixels are flagged as ``non_finite_data``. n_threads : int, optional The number of threads to use to compute the aperture sums. When ``n_threads`` > 1, the aperture positions are divided into chunks that are processed concurrently. The per-source results are independent, so they are identical to the single-threaded computation. The per-image preprocessing (e.g., the mask plane) is computed once and shared read-only across the workers. Returns ------- result : tuple of `~numpy.ndarray` or `None` A ``(aperture_sums, aperture_sum_errs, areas, flag_counts, overlap)`` tuple, or `None` if the batch driver does not support this aperture or these inputs (in which case the caller should use the mask-based code path). The ``flag_counts`` columns are given by the ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`. On this code path the ``FLAG_COL_BBOX_CLIPPED`` column is only a candidate indicator (bounding box clipped by a data edge) that the caller must resolve to the precise outside-weight test (see `_resolve_outside_weights`). """ inputs = batch_driver_inputs( self, data, error=error, mask=mask, method=method, subpixels=subpixels, segmentation=segmentation, labels=labels, mask_method=mask_method) if inputs is None: return None positions = inputs.positions labels_arr = inputs.labels def run_sums(pos, src_labels): return batch_aperture_sums( inputs.data, inputs.error, inputs.mask, pos, inputs.shape_code, inputs.params, inputs.ext_x, inputs.ext_y, inputs.off_x, inputs.off_y, inputs.use_exact, inputs.subpixels, inputs.segmentation, src_labels, inputs.seg_method, mask_nonfinite=int(mask_nonfinite)) # The per-source outside-weight indicator of the driver is not # used here, because the aperture flags resolve the clipped # bounding boxes with ``_resolve_outside_weights``. n_chunks = min(n_threads, positions.shape[0]) if n_chunks > 1: # Row slices of the C-contiguous positions and labels # arrays are themselves C-contiguous, so the chunks can be # passed directly to the Cython driver. pos_chunks = np.array_split(positions, n_chunks) if labels_arr is None: labels_chunks = [None] * n_chunks else: labels_chunks = np.array_split(labels_arr, n_chunks) with ThreadPoolExecutor(max_workers=n_chunks) as executor: results = list(executor.map(run_sums, pos_chunks, labels_chunks)) sums = np.concatenate([result.sums for result in results]) sum_var = np.concatenate([result.sum_vars for result in results]) area = np.concatenate([result.areas for result in results]) overlap = np.concatenate([result.overlap for result in results]) fcounts = np.concatenate([result.flag_counts for result in results]) else: result = run_sums(positions, labels_arr) sums = result.sums sum_var = result.sum_vars area = result.areas overlap = result.overlap fcounts = result.flag_counts if inputs.error is None: # Match the mask-based path, which returns an all-NaN error # array (with the same length as the fluxes) when error is # not input. errs = np.full(sums.shape, np.nan) else: errs = np.sqrt(sum_var) return sums, errs, area, fcounts, overlap @_update_method_subpixels_docstring def _photometry(self, data, *, error=None, mask=None, method='exact', subpixels=5, segmentation_image=None, labels=None, mask_method='none', mask_nonfinite=False, n_threads=1): # numpydoc ignore: PR01,PR02,PR04,PR07 """ 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. mask_nonfinite : bool, optional Whether to mask nonfinite ``data`` values (matching `ApertureStats`) instead of leaving them in the computation, where they corrupt the sum (the 3.0.0 behavior, used by the legacy `aperture_photometry` function). In both cases, the nonfinite pixels are flagged as ``non_finite_data``. n_threads : int, optional The number of threads to use to compute the aperture sums on the batch code path (see `_batch_photometry`). The mask-based code path is always serial. Returns ------- result : `_ApertureResults` The aperture photometry results. It has the following attributes: - ``flux`` : `~numpy.ndarray` or `~astropy.units.Quantity` The sum within each aperture. The values are always float64, regardless of the input ``data`` dtype. - ``flux_err`` : `~numpy.ndarray` or `~astropy.units.Quantity` The errors on the sum within each aperture. The values are always float64, regardless of the input ``error`` dtype. - ``area`` : `~astropy.units.Quantity` The total unmasked overlap area of each aperture (in ``pix**2``), taking into account the aperture mask method, masked data pixels, segmentation masking, and partial/no overlap of the aperture with the data. This is equivalent to `area_overlap` computed with the same inputs when no segmentation masking is applied and there are no nonfinite data values (`area_overlap` takes neither into account). The value is ``NaN`` where the aperture does not overlap the data. - ``flags`` : `~numpy.ndarray` The bitwise quality flags for each aperture. See `~photutils.aperture.decode_aperture_flags` for decoding flag values. The flags are: """ 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) mask = _validate_mask(mask, data.shape) # 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 segmentation, labels = process_segmentation_inputs( segmentation_image, labels, mask_method, np.atleast_2d(self.positions), data.shape) result = self._batch_photometry( data, error=error, mask=mask, method=method, subpixels=subpixels, segmentation=segmentation, labels=labels, mask_method=mask_method, mask_nonfinite=mask_nonfinite, n_threads=n_threads) if result is not None: flux, flux_err, area, fcounts, overlap = result else: (flux, flux_err, area, fcounts, overlap) = self._mask_photometry( data, error=error, mask=mask, method=method, subpixels=subpixels, segmentation=segmentation, labels=labels, mask_method=mask_method, mask_nonfinite=mask_nonfinite) # Resolve the precise outside-weight test only for the sources # whose bounding box is clipped by a data edge candidates = fcounts[:, FLAG_COL_BBOX_CLIPPED].astype(bool) w_out = self._resolve_outside_weights( data.shape, method=method, subpixels=subpixels, candidates=candidates) flags = _counts_to_flag_bits(fcounts, overlap, w_out) # The results are undefined for an aperture with no weighted # pixel inside the data, including one whose bounding box # overlaps the data. no_overlap = (flags & APERTURE_FLAGS.NO_OVERLAP) != 0 flux[no_overlap] = np.nan flux_err[no_overlap] = np.nan area[no_overlap] = np.nan # Apply units if unit is not None: flux <<= unit flux_err <<= unit # The area always has units of pix**2, regardless of whether # data/error have units (matches ApertureStats.sum_aper_area). area <<= (u.pix**2) return _ApertureResults(flux=flux, flux_err=flux_err, area=area, flags=flags) @_update_method_subpixels_docstring @deprecated(since='3.1', alternative='AperturePhotometry', until='4.0') def do_photometry(self, data, error=None, mask=None, method='exact', subpixels=5): # numpydoc ignore: PR01,PR02,PR04,PR07 """ 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. 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. The values are NaN if ``error`` is not input. """ result = self._photometry(data, error=error, mask=mask, method=method, subpixels=subpixels) return result.flux, result.flux_err def _resolve_outside_weights(self, shape, *, method, subpixels, candidates): """ Whether each aperture has nonzero mask weights outside the data. For the ``'exact'`` method the candidates are returned unchanged, because a clipped minimal bounding box always leaves part of the aperture outside the data. For the other methods the precise per-source test is evaluated (via per-source aperture masks) only for the candidate sources, i.e., those whose bounding box is clipped by a data edge. The result is `False` for all other sources. Interior sources are never candidates, so no aperture masks are built for them. Parameters ---------- shape : tuple of int The shape of the data array. method, subpixels See `_photometry`. candidates : `~numpy.ndarray` (bool) Whether each aperture bounding box is clipped by a data edge. Returns ------- w_out : `~numpy.ndarray` (bool) Whether each aperture has one or more pixels with nonzero aperture weight outside the data. """ # For the 'exact' method the minimal bounding box is tight # (the aperture is tangent to each bbox side), so a bbox that # is clipped by a data edge always leaves a positive-area # portion of the aperture outside the data. The precise # outside-weight test therefore agrees exactly with the # bbox-clipped candidates, and no per-source aperture masks need # to be built. if method == 'exact': return candidates.copy() w_out = np.zeros(candidates.shape, dtype=bool) idx = np.flatnonzero(candidates) if idx.size == 0: return w_out sub = self if self.isscalar else self[idx] apermasks = sub.to_mask(method=method, subpixels=subpixels) if sub.isscalar: apermasks = [apermasks] for i, apermask in zip(idx, apermasks, strict=True): n_total = np.count_nonzero(apermask.data) slc_large, slc_small = apermask.get_overlap_slices(shape) if slc_large is None: w_out[i] = n_total > 0 else: n_in = np.count_nonzero(apermask.data[slc_small]) w_out[i] = n_total > n_in return w_out @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 The matplotlib patch keywords, consisting of the default patch properties updated with ``kwargs``. """ 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 : int or str, optional An index appended to the ``aperture`` prefix of each metadata key (e.g., ``aperture1_r`` for ``index=1``). Returns ------- meta : dict A dictionary of aperture metadata. It holds the aperture class name and each aperture parameter except ``positions``. """ 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-24be529/photutils/aperture/ellipse.py000066400000000000000000001033131526452531300235020ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Elliptical and elliptical-annulus apertures in both pixel and sky coordinates. """ import math from functools import cached_property import astropy.units as u import numpy as np from astropy.coordinates import Angle from photutils.aperture._batch_photometry import (SHAPE_ELLIPSE, SHAPE_ELLIPTICAL_ANNULUS) from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, ScalarAngle, ScalarAngleOrValue, SkyCoordPositions) from photutils.aperture.core import (PixelAperture, SkyAperture, _enable_batch_photometry, _RotatableApertureMixin, _update_method_subpixels_docstring) from photutils.aperture.mask import ApertureMask from photutils.aperture.polygon import PolygonAperture, SkyPolygonAperture from photutils.geometry import elliptical_overlap_grid from photutils.utils._deprecation import (deprecated, deprecated_positional_kwargs) from photutils.utils._wcs_helpers import (pixel_shape_to_sky_svd, sky_shape_to_pixel_svd) __all__ = [ 'EllipticalAnnulus', 'EllipticalAperture', 'EllipticalMaskMixin', 'SkyEllipticalAnnulus', 'SkyEllipticalAperture', ] def _elliptical_polygon_offsets(a, b, theta, n_vertices, *, swap_axes=False): """ Compute the vertex offsets that approximate an ellipse with semimajor axis ``a``, semiminor axis ``b``, and position angle ``theta`` (in radians) using ``n_vertices`` vertices equally spaced in the parametric angle. Parameters ---------- a : float The semimajor axis of the ellipse. b : float The semiminor axis of the ellipse. theta : float The rotation angle of the ellipse in radians. With ``swap_axes=False`` (the pixel convention), it is measured counterclockwise from the first (``x``) coordinate axis. With ``swap_axes=True`` (the sky convention), it is the position angle measured from North (+latitude) toward East (+longitude), where PA=0 places the semimajor axis along North. n_vertices : int The number of vertices used to approximate the ellipse. Must be at least 3. swap_axes : bool, optional If `True`, the unrotated semimajor axis is placed along the second coordinate axis instead of the first. This matches the sky-aperture convention where, at ``theta=0``, the semimajor axis is along +latitude (North) and the semiminor axis is along +longitude. Returns ------- offsets : 2D `~numpy.ndarray` The vertex offsets that approximate the ellipse. The shape is (n_vertices, 2) where the columns correspond to the x and y coordinate offsets, respectively. """ n_vertices = int(n_vertices) if n_vertices < 3: msg = f'n_vertices must be at least 3, got {n_vertices}' raise ValueError(msg) angles = np.linspace(0.0, 2.0 * np.pi, n_vertices, endpoint=False) x = a * np.cos(angles) y = b * np.sin(angles) if swap_axes: # A counterclockwise rotation in the (longitude, latitude) # plane moves North toward West, so the sign of the position # angle is flipped. x, y = y, x theta = -theta cos_t = np.cos(theta) sin_t = np.sin(theta) return np.column_stack([x * cos_t - y * sin_t, x * sin_t + y * cos_t]) @deprecated('3.0', until='3.2') class EllipticalMaskMixin: # pragma: no cover """ Mixin class to create masks for elliptical and elliptical-annulus aperture objects. """ @_update_method_subpixels_docstring def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- 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 mask = elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, a, b, self._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, self._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_rad): """ Calculate half of the bounding box extents of an ellipse. """ return _calc_ellipse_extents(semimajor_axis, semiminor_axis, theta_rad) def _calc_ellipse_extents(semimajor_axis, semiminor_axis, theta_rad): """ Calculate half of the bounding box extents of an ellipse. """ 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 @_enable_batch_photometry class EllipticalAperture(_RotatableApertureMixin, 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 not positive. 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 @cached_property def _xy_extents(self): """ 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_rad) def _batch_shape_params(self): return SHAPE_ELLIPSE, (self.a, self.b, self._theta_rad) @cached_property 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_value(u.deg) 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 @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. """ return elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.a, self.b, self._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_shape_to_sky_svd( wcs, pixcoord, 2 * self.a, 2 * self.b, self._theta_rad) a = Angle(sky_width / 2, 'arcsec') b = Angle(sky_height / 2, 'arcsec') return SkyEllipticalAperture(positions=positions, a=a, b=b, theta=sky_angle) def to_polygon(self, *, n_vertices=100): """ Return a `~photutils.aperture.PolygonAperture` that approximates this elliptical aperture. Parameters ---------- n_vertices : int, optional The number of polygon vertices used to approximate the ellipse. Must be at least 3. Default is 100. Returns ------- aperture : `~photutils.aperture.PolygonAperture` A polygon aperture that approximates the ellipse. """ offsets = _elliptical_polygon_offsets( self.a, self.b, self.theta.to_value(u.radian), n_vertices) return PolygonAperture._from_simple_offsets(self.positions, offsets) @_enable_batch_photometry class EllipticalAnnulus(_RotatableApertureMixin, 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 the inner semimajor axis (``a_in``) is not less than the outer semimajor axis (``a_out``), or if the inner semiminor axis (``b_in``), when given, is not less than the outer semiminor axis (``b_out``). ValueError : `ValueError` If any of the axes (``a_in``, ``a_out``, ``b_in``, or ``b_out``) is not positive. 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') _ordered_pairs = (('a_in', 'a_out'), ('b_in', 'b_out')) 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): 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 self.b_in = b_in self.theta = theta @cached_property def _xy_extents(self): """ 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_rad) def _batch_shape_params(self): return SHAPE_ELLIPTICAL_ANNULUS, (self.a_in, self.b_in, self.a_out, self.b_out, self._theta_rad) @cached_property 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_value(u.deg) 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 @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. """ overlap = elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.a_out, self.b_out, self._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, self._theta_rad, use_exact, subpixels) # Clip tiny negative values arising from floating-point noise in # the subtraction. np.maximum(overlap, 0.0, out=overlap) 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. Only the outer ellipse is converted with the WCS. The inner axes are the converted outer axes scaled by the input ratios ``a_in / a_out`` and ``b_in / b_out``, so the inner axes always remain smaller than the outer axes and the inner shape is recovered exactly by the inverse conversion. The annulus has a single rotation angle, which is that of the converted outer ellipse. If the WCS is sheared and the inner aspect ratio differs from the outer one, the inner ellipse of the returned annulus is an approximation of the converted inner ellipse, which would have a different orientation. """ 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])) # Convert the outer ellipse. The inner axes keep their input # ratios to the outer axes, which keeps the annulus valid for # any WCS. sky_w, sky_h, sky_angle = pixel_shape_to_sky_svd( wcs, pixcoord, 2 * self.a_out, 2 * self.b_out, self._theta_rad) a_out = Angle(sky_w / 2, 'arcsec') b_out = Angle(sky_h / 2, 'arcsec') a_in = a_out * (self.a_in / self.a_out) b_in = b_out * (self.b_in / self.b_out) 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(_RotatableApertureMixin, 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, measured from North (PA=0) toward East. This is counterclockwise on an image displayed with North up and East to the left. North and East are the directions of increasing latitude and longitude in the coordinate frame of ``positions`` (e.g., ICRS or Galactic). When the aperture is converted to pixels with a WCS whose celestial frame differs from that frame, the angle is converted to the equivalent angle in the WCS frame, so the aperture keeps the same orientation on the sky. Raises ------ TypeError : `TypeError` If either axis (``a`` or ``b``) is not a `~astropy.units.Quantity`. ValueError : `ValueError` If either axis (``a`` or ``b``) is not positive. 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] first_pixcoord = tuple(np.atleast_2d(positions)[0]) _, pix_width, pix_height, pix_angle = sky_shape_to_pixel_svd( wcs, skypos, 2 * self.a.to_value(u.arcsec), 2 * self.b.to_value(u.arcsec), self._theta_rad, pixcoord=first_pixcoord) a = pix_width / 2 b = pix_height / 2 return EllipticalAperture(positions=positions, a=a, b=b, theta=pix_angle) def to_polygon(self, *, n_vertices=100): """ Return a `~photutils.aperture.SkyPolygonAperture` that approximates this elliptical aperture. Parameters ---------- n_vertices : int, optional The number of polygon vertices used to approximate the ellipse. Must be at least 3. Default is 100. Returns ------- aperture : `~photutils.aperture.SkyPolygonAperture` A sky polygon aperture that approximates the ellipse. """ unit = self.a.unit offsets = _elliptical_polygon_offsets( self.a.to_value(unit), self.b.to_value(unit), self.theta.to_value(u.radian), n_vertices, swap_axes=True) * unit return SkyPolygonAperture._from_simple_offsets(self.positions, offsets) class SkyEllipticalAnnulus(_RotatableApertureMixin, 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 : scalar `~astropy.units.Quantity` or `None`, optional 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, measured from North (PA=0) toward East. This is counterclockwise on an image displayed with North up and East to the left. North and East are the directions of increasing latitude and longitude in the coordinate frame of ``positions`` (e.g., ICRS or Galactic). When the aperture is converted to pixels with a WCS whose celestial frame differs from that frame, the angle is converted to the equivalent angle in the WCS frame, so the aperture keeps the same orientation on the sky. Raises ------ TypeError : `TypeError` If any of the axes (``a_in``, ``a_out``, ``b_in``, or ``b_out``) is not a `~astropy.units.Quantity`. ValueError : `ValueError` If the inner semimajor axis (``a_in``) is not less than the outer semimajor axis (``a_out``), or if the inner semiminor axis (``b_in``), when given, is not less than the outer semiminor axis (``b_out``). ValueError : `ValueError` If any of the axes (``a_in``, ``a_out``, ``b_in``, or ``b_out``) is not positive. 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') _ordered_pairs = (('a_in', 'a_out'), ('b_in', 'b_out')) 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): 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 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. Only the outer ellipse is converted with the WCS. The inner axes are the converted outer axes scaled by the input ratios ``a_in / a_out`` and ``b_in / b_out``, so the inner axes always remain smaller than the outer axes and the inner shape is recovered exactly by the inverse conversion. The annulus has a single rotation angle, which is that of the converted outer ellipse. If the WCS is sheared and the inner aspect ratio differs from the outer one, the inner ellipse of the returned annulus is an approximation of the converted inner ellipse, which would have a different orientation. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] first_pixcoord = tuple(np.atleast_2d(positions)[0]) # Convert the outer ellipse. The inner axes keep their input # ratios to the outer axes, which keeps the annulus valid for # any WCS. a_out_arcsec = self.a_out.to_value(u.arcsec) b_out_arcsec = self.b_out.to_value(u.arcsec) _, pix_w, pix_h, pix_angle = sky_shape_to_pixel_svd( wcs, skypos, 2 * a_out_arcsec, 2 * b_out_arcsec, self._theta_rad, pixcoord=first_pixcoord) a_out = pix_w / 2 b_out = pix_h / 2 a_in = a_out * (self.a_in.to_value(u.arcsec) / a_out_arcsec) b_in = b_out * (self.b_in.to_value(u.arcsec) / b_out_arcsec) 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-24be529/photutils/aperture/flags.py000066400000000000000000000363521526452531300231510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for working with aperture photometry and statistics flags, including centralized flag definitions and decoding utilities. """ from typing import ClassVar import numpy as np from photutils.utils._flags import (FlagDefinition, FlagRegistry, decode_flags, update_flag_docstring) __all__ = ['APERTURE_FLAGS', 'decode_aperture_flags'] class _ApertureFlags(FlagRegistry): """ Centralized definition of aperture photometry and statistics flags. This class provides a single source of truth for all aperture flag definitions, including bit values, names, and descriptions. The same flag definitions are used by `~photutils.aperture.PixelAperture._photometry`, `~photutils.aperture.AperturePhotometry`, and `~photutils.aperture.ApertureStats`, so a given bit always has the same meaning. Examples -------- >>> from photutils.aperture.flags import _ApertureFlags >>> flags = _ApertureFlags() >>> flags.NO_OVERLAP 1 >>> flags.get_name(1) 'no_overlap' >>> flags.get_description(8) 'masked pixels within the aperture' """ # Define all aperture flags with their properties FLAG_DEFINITIONS: ClassVar = [ FlagDefinition( bit_value=1, name='no_overlap', description='aperture fully outside the data', detailed_description=('The aperture is fully outside the ' 'data array: no pixel with nonzero ' 'aperture weight falls inside the ' 'data.'), ), FlagDefinition( bit_value=2, name='partial_overlap', description='aperture partially outside the data', detailed_description=('The aperture is partially outside ' 'the data array: one or more pixels ' 'with nonzero aperture weight fall ' 'outside the data.'), ), FlagDefinition( bit_value=4, name='no_pixels', description='no aperture pixels within the data', detailed_description=('The aperture contains zero pixels ' 'with nonzero weight inside the data, ' 'e.g., a fully off-image aperture or ' 'a tiny aperture that contains no ' 'pixel (or subpixel) centers with ' 'the "center" or "subpixel" methods.'), ), FlagDefinition( bit_value=8, name='masked_pixels', description='masked pixels within the aperture', detailed_description=('One or more input-masked pixels ' '(``mask`` keyword) have nonzero ' 'aperture weight.'), ), FlagDefinition( bit_value=16, name='non_finite_data', description='nonfinite data values within the aperture', detailed_description=('One or more unmasked data values ' 'with nonzero aperture weight are ' 'nonfinite (NaN or inf).'), ), FlagDefinition( bit_value=32, name='non_finite_error', description='nonfinite error values within the aperture', detailed_description=('One or more unmasked error values ' 'with nonzero aperture weight are ' 'nonfinite (NaN or inf).'), ), FlagDefinition( bit_value=64, name='neighbor_pixels', description='segmentation-masked pixels within the aperture', detailed_description=('One or more pixels within the ' 'aperture were excluded, restricted, ' 'or corrected due to neighboring ' 'sources in the segmentation image.'), ), FlagDefinition( bit_value=128, name='uncorrected_pixels', description='uncorrectable neighbor pixels within the aperture', detailed_description=('With ``mask_method="correct"``, one ' 'or more neighbor-source pixels could ' 'not be corrected (the mirror pixel ' 'was unavailable) and were excluded ' 'instead.'), ), FlagDefinition( bit_value=256, name='all_masked', description='no valid pixels within the aperture', detailed_description=('The aperture contains pixels, but ' 'none are valid: every nonzero-weight ' 'pixel inside the data is masked, ' 'nonfinite, or excluded by ' 'segmentation masking.'), ), FlagDefinition( bit_value=512, name='sigma_clipped', description='sigma-clipped pixels within the aperture', detailed_description=('One or more pixels within the ' 'aperture were rejected by sigma ' 'clipping.'), ), FlagDefinition( bit_value=1024, name='all_clipped', description='all pixels within the aperture were sigma clipped', detailed_description=('All valid pixels within the aperture ' 'were rejected by sigma clipping.'), ), FlagDefinition( bit_value=2048, name='too_few_pixels', description='too few pixels to compute a statistic', detailed_description=('There are too few valid pixels ' 'within the aperture to compute a ' 'requested statistic (e.g., the ' 'standard errors are undefined for ' 'a single valid pixel, and ' 'the variance and standard deviation ' 'are undefined when the number of ' 'valid pixels is not larger than ' '``ddof``).'), ), FlagDefinition( bit_value=4096, name='undefined_shape', description=('nonpositive net flux or invalid covariance ' '(shape properties undefined)'), detailed_description=('The net flux within the aperture ' '(the zeroth image moment of the ' 'unmasked "center"-method pixels) is ' 'not positive, so the centroid and ' 'the covariance-derived shape ' 'properties (e.g., ``centroid``, ' '``semimajor_axis``, ``orientation``)' ' are undefined or unreliable. The ' 'flag is also set when the ' 'second-order moments are not ' 'positive semidefinite (e.g., from ' 'negative pixel values), in which ' 'case the covariance-derived shape ' 'properties are NaN. ' 'Fully masked sources have a zero ' 'net flux, so they are also ' 'flagged. Sources with no overlap ' 'and fully sigma-clipped sources ' 'are not flagged here. They are ' 'reported by the overlap and ' 'clipping flags.'), ), FlagDefinition( bit_value=8192, name='singular_covariance', description='singular or nearly singular source covariance', detailed_description=('The source covariance matrix is ' 'singular or nearly singular (the ' 'minor-axis variance is below ' '``1/12``, the variance of a uniform ' 'distribution across a single pixel), ' 'so covariance-derived shape ' 'properties (e.g., ``semimajor_axis``, ' '``orientation``, ``eccentricity``) are ' 'ill-defined and have been ' 'regularized.'), ), FlagDefinition( bit_value=16384, name='centroid_outside_bbox', description='centroid is outside the aperture bounding box', detailed_description=('The ``centroid`` lies outside the ' 'bounding box of the aperture. The ' 'image moments include negative ' 'pixel values, so the centroid is ' 'not bounded by the aperture when ' 'the net flux is small compared to ' 'the noise. The centroid and the ' 'shape properties are unreliable.'), ), ] domain: ClassVar = 'aperture' # Create a singleton instance for global use APERTURE_FLAGS = _ApertureFlags() def _update_decode_docstring(func): """ Decorator to update a function docstring with the aperture flag documentation. The ```` placeholder in the function docstring is replaced with a bullet list generated from ``APERTURE_FLAGS`` (see `photutils.utils._flags.update_flag_docstring`). Parameters ---------- func : function The function to decorate. Returns ------- func : function The decorated function with updated docstring. """ return update_flag_docstring(func, APERTURE_FLAGS, indent=None) @_update_decode_docstring def decode_aperture_flags(flags, *, return_bit_values=False): # numpydoc ignore: RT05 """ Decode aperture bit flags into individual components. This function takes integer flag values from aperture photometry or aperture statistics results and returns a list of human-readable names of the conditions that were detected. 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 was detected when measuring the aperture. return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag names (strings). Default is `False`. Returns ------- decoded : list of str, list of int, or nested list List of active flag names (or bit values) for a scalar input. For an array input, a nested list with the same shape as the input is returned, where each innermost element is the list of active flag names (or bit values) for the corresponding flag. If no flags are set, an empty list is returned. Possible flags are: Examples -------- Decode a single flag value: >>> from photutils.aperture import decode_aperture_flags >>> issues = decode_aperture_flags(10) # bits 2 and 8 set >>> print(issues) ['partial_overlap', 'masked_pixels'] >>> 'partial_overlap' in issues True >>> 'no_overlap' in issues False Decode multiple flag values: >>> flags = [0, 8, 264] # 0, bit 8, bits 8+256 >>> decoded_list = decode_aperture_flags(flags) >>> decoded_list[0] # No issues [] >>> decoded_list[1] ['masked_pixels'] >>> decoded_list[2] ['masked_pixels', 'all_masked'] Return the bit values instead of the names: >>> decode_aperture_flags(10, return_bit_values=True) [2, 8] """ return decode_flags(flags, APERTURE_FLAGS, return_bit_values=return_bit_values) def _counts_to_flag_bits(flag_counts, overlap, w_out): """ Map per-source pixel counts to aperture flag bit values. Parameters ---------- flag_counts : 2D `~numpy.ndarray` (int) The per-source pixel counts, with one row per source and the columns given by the ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`. Rows must be all zero for sources whose aperture bounding box does not overlap the data. overlap : 1D `~numpy.ndarray` (bool) Whether the aperture bounding box overlaps the data. w_out : 1D `~numpy.ndarray` (bool) Whether the aperture has one or more pixels with nonzero aperture weight outside the data. Returns ------- flags : 1D `~numpy.ndarray` (int) The bitwise quality flags for each source. """ # Import here to avoid loading the compiled extension when only the # registry or decoder is needed from photutils.aperture._batch_photometry import (FLAG_COL_MASKED, FLAG_COL_N_PIXELS, FLAG_COL_NONFINITE_DATA, FLAG_COL_NONFINITE_ERROR, FLAG_COL_SEG, FLAG_COL_UNCORRECTED, FLAG_COL_VALID) flag_counts = np.atleast_2d(flag_counts) overlap = np.atleast_1d(overlap) w_out = np.atleast_1d(w_out) w_in = flag_counts[:, FLAG_COL_N_PIXELS] flags = np.zeros(overlap.shape, dtype=int) no_overlap = ~overlap | ((w_in == 0) & w_out) flags[no_overlap] |= APERTURE_FLAGS.NO_OVERLAP flags[(w_in > 0) & w_out] |= APERTURE_FLAGS.PARTIAL_OVERLAP flags[w_in == 0] |= APERTURE_FLAGS.NO_PIXELS # For sources without overlap, the counts are all zero, so the # pixel-membership bits below are never set for them. masked = flag_counts[:, FLAG_COL_MASKED] > 0 flags[masked] |= APERTURE_FLAGS.MASKED_PIXELS all_masked = (w_in > 0) & (flag_counts[:, FLAG_COL_VALID] == 0) flags[all_masked] |= APERTURE_FLAGS.ALL_MASKED nonfinite = flag_counts[:, FLAG_COL_NONFINITE_DATA] > 0 flags[nonfinite] |= APERTURE_FLAGS.NON_FINITE_DATA nonfinite_err = flag_counts[:, FLAG_COL_NONFINITE_ERROR] > 0 flags[nonfinite_err] |= APERTURE_FLAGS.NON_FINITE_ERROR neighbor = flag_counts[:, FLAG_COL_SEG] > 0 flags[neighbor] |= APERTURE_FLAGS.NEIGHBOR_PIXELS uncorrected = flag_counts[:, FLAG_COL_UNCORRECTED] > 0 flags[uncorrected] |= APERTURE_FLAGS.UNCORRECTED_PIXELS return flags astropy-photutils-24be529/photutils/aperture/mask.py000066400000000000000000000312431526452531300230020ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Class for aperture masks. """ import warnings from functools import cached_property 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 @cached_property def _mask(self): """ A boolean array that is `True` where the aperture mask weight is zero. """ return 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` or `None` A 2D array of the mask. `None` is returned if there is no overlap of the mask with the output image shape. """ 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 The 2D array from which to extract the cutout. 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. For a partial overlap, the cutout has the data type of ``data``, so ``fill_value`` is cast to that type. A nonfinite ``fill_value`` gives a float cutout. copy : bool, optional If `True` then the returned cutout array will always 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``. If the mask only partially overlaps the input ``data``, the returned cutout array is always a copy (i.e., this keyword has no effect). Returns ------- result : `~numpy.ndarray`, `~astropy.units.Quantity`, 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 not 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 assigned to pixels where the aperture mask does not overlap with the input ``data`` and to pixels within the bounding box where the mask weight is zero. Pixels outside of the input ``data`` that have a nonzero mask weight are set to ``fill_value`` multiplied by the mask weight. The default is 0. Returns ------- result : `~numpy.ndarray`, `~astropy.units.Quantity`, 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 nonfinite 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` or `None` The cutout aperture mask weights for the overlap. `None` is returned if there is no overlap. pixel_mask : 2D bool `~numpy.ndarray` or `None` The cutout pixel mask for the overlap, where `True` indicates a pixel with a nonzero aperture weight that is not masked in the input ``mask``. `None` is returned if there is no 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). """ 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 : 2D `~numpy.ndarray` or `~astropy.units.Quantity` The 2D array from which to get mask-weighted values. 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 not returned in the result. Returns ------- result : `~numpy.ndarray` or `~astropy.units.Quantity` A 1D array of mask-weighted pixel values from the input ``data``. Only pixels with a nonzero mask weight that lie within ``data`` are returned. If there is no overlap of the aperture with the input ``data``, the result will be an empty array with shape (0,). If ``data`` is a `~astropy.units.Quantity`, the result is a `~astropy.units.Quantity` with the same units. """ slc_large, aper_weights, pixel_mask = self._get_overlap_cutouts( data.shape, mask=mask) if slc_large is None: values = np.array([]) if isinstance(data, u.Quantity): values <<= data.unit return values # Ignore multiplication with nonfinite 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-24be529/photutils/aperture/photometry.py000066400000000000000000001004731526452531300242630ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for performing aperture photometry. """ from functools import cached_property import astropy.units as u import numpy as np from astropy.nddata import NDData from astropy.table import QTable from photutils.aperture._common import (SCALAR_COLLAPSE_TYPES, collapse_scalar_value, unpack_nddata, validate_array, validate_mask_method) from photutils.aperture._segmentation import process_segmentation_inputs from photutils.aperture.core import (Aperture, SkyAperture, _aperture_metadata, _update_method_subpixels_docstring) from photutils.aperture.flags import decode_aperture_flags from photutils.aperture.region_converters import region_to_aperture from photutils.utils._deprecation import (create_empty_deprecated_qtable, deprecated_positional_kwargs) from photutils.utils._misc import _get_meta from photutils.utils._parameters import validate_table_columns from photutils.utils._quantity_helpers import process_quantities from photutils.utils._repr import make_repr __all__ = ['AperturePhotometry', 'aperture_photometry'] # Remove in 4.0 _DEPRECATED_COLUMNS: dict = { 'xcenter': 'x_center', 'ycenter': 'y_center', } # Public attributes that are never collapsed to a scalar for a scalar # instance because they are not per-position output values (see # ``AperturePhotometry.__getattribute__``). ``segmentation_image`` and # ``labels`` are the inputs echoed back to the user, and the others # describe the whole object. Any new public attribute that is a list, # tuple, ndarray, or SkyCoord but is not a per-position value must be # added here, otherwise a length-1 value will be silently collapsed to # its first element for a scalar instance. _SCALAR_EXCLUDE = frozenset({'default_columns', 'isscalar', 'labels', 'n_positions', 'segmentation_image'}) @_update_method_subpixels_docstring class AperturePhotometry: # numpydoc ignore: PR01,PR02,PR04,PR07 """ Class to perform aperture photometry on 2D data. This class sums the (weighted) input ``data`` values within the given aperture(s) and provides the aperture fluxes, uncertainties, unmasked overlap areas, and bitwise quality flags as lazily-computed attributes. Use `to_table` to obtain the results as an `~astropy.table.QTable`. Note that this class 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 class. 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. Nonfinite ``data`` values (NaN and inf) are automatically masked. 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). A tuple or `~numpy.ndarray` of them is also accepted. The list must not be empty. 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. Nonfinite values (NaN and inf) in the input ``data`` are automatically masked. 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 `sky_center` attribute and the ``'sky_center'`` column of `to_table` will contain the sky coordinates of the input aperture center(s). This keyword is required if the input ``apertures`` contains a `SkyAperture` or `~regions.SkyRegion`. n_threads : int, optional The number of threads to use to compute the aperture sums. The default is 1 (no multithreading). When ``n_threads`` > 1, the aperture positions are divided into chunks and processed concurrently. The per-position results are independent, so they are identical to the single-threaded computation. The underlying kernels release the Python global interpreter lock (GIL), so multithreading can significantly speed up the photometry for many aperture positions. Multithreading is used only for apertures and input arrays supported by the fast batch code path. Otherwise, the computation is serial. See Also -------- photutils.aperture.ApertureStats : Per-source statistics (e.g., centroid, min, max, median, standard deviation, and morphological properties) of the pixels within an aperture. Notes ----- `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. If the input ``data`` is a `~astropy.nddata.NDData` object, then its ``mask``, ``wcs``, unit, and uncertainty are used. The ``error``, ``mask``, and ``wcs`` keywords are then ignored, and an `~astropy.utils.exceptions.AstropyUserWarning` is issued for each one that is input. The uncertainty is used as ``error`` only if it is a `~astropy.nddata.StdDevUncertainty`. Any other uncertainty type is ignored. The measured `flux`, `flux_err`, `area`, and `flags` attributes (as well as `id`, `x_center`, `y_center`, and `sky_center`) are scalars if a single scalar aperture position is input (e.g., ``CircularAperture((10, 20), r=5)``). They are 1D arrays with one element per position if multiple positions are input (e.g., ``CircularAperture([(10, 20)], r=5)`` or ``CircularAperture([(10, 20), (30, 40)], r=5)``). If a list of apertures is input, the `flux`, `flux_err`, `area`, and `flags` attributes gain a trailing aperture axis, giving shape ``(n_apertures,)`` for a single scalar position and ``(n_positions, n_apertures)`` otherwise. Nonfinite ``data`` values (NaN and inf) are automatically masked. Such pixels are excluded from the `flux`, `flux_err`, and `area` calculations and are indicated by the ``non_finite_data`` quality flag (see `~photutils.aperture.decode_aperture_flags`). This class should be treated as immutable after initialization (aside from the internal lazily-computed `functools.cached_property` cache), so a single instance can be safely shared across threads. Do not reassign its attributes after construction. One caveat applies to apertures that are not supported by the compiled batch code path (e.g., a `~photutils.aperture.Aperture` subclass that overrides ``to_mask``). That path suppresses warnings using `warnings.catch_warnings`, which mutates process-global warning-filter state, so concurrent calls can transiently affect the warning filters seen by other threads. On free-threaded Python builds the warning filters are context-aware, so this does not apply. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.aperture import AperturePhotometry, CircularAperture >>> data = make_4gaussians_image() >>> error = 0.1 * np.ones(data.shape) # simple background-only error >>> aper = CircularAperture([(25, 40), (90, 60), (150, 25)], r=8) >>> phot = AperturePhotometry(data, aper, error=error) >>> print(phot.flux) [ 5853.59627292 28440.27461471 9286.70920641] >>> print(phot.flux_err) [1.38553058 1.38553058 1.38553058] >>> print(phot.to_table(columns=['id', 'flux', 'flux_err'])) id flux flux_err --- ------------------ ------------------ 1 5853.596272924398 1.3855305772442865 2 28440.274614708058 1.3855305772442865 3 9286.709206410273 1.3855305772442865 """ _repr_params = ('method', 'subpixels', 'mask_method', 'n_threads') def __init__(self, data, apertures, *, error=None, mask=None, wcs=None, method='exact', subpixels=5, segmentation_image=None, labels=None, mask_method='none', n_threads=1): if isinstance(data, NDData): data, error, mask, wcs = unpack_nddata(data, error, mask, wcs) (data, error), unit = process_quantities( (data, error), ('data', 'error')) self._data = validate_array(data, 'data') self._data_unit = unit data_shape = self._data.shape self._error = validate_array(error, 'error', shape=data_shape) self._mask = validate_array(mask, 'mask', shape=data_shape) self._wcs = wcs # Validate the mask-method keywords here so that an invalid # value is reported at construction rather than at the first # access of a measured attribute, far from its cause. validate_mask_method(method, subpixels, method_name='method') self.method = method self.subpixels = subpixels self.mask_method = mask_method if (isinstance(n_threads, bool) or not isinstance(n_threads, (int, np.integer)) or n_threads < 1): msg = 'n_threads must be a positive integer' raise ValueError(msg) self.n_threads = int(n_threads) single_aperture = False if not isinstance(apertures, (list, tuple, np.ndarray)): single_aperture = True apertures = (apertures,) elif len(apertures) == 0: msg = 'apertures must not be empty' raise ValueError(msg) self._single_aperture = single_aperture # Create table metadata using the input apertures, not the # converted ones aper_meta = {} for i, aperture in enumerate(apertures): idx = '' if single_aperture else i aper_meta.update(_aperture_metadata(aperture, index=idx)) # 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 self._skyaper = False self._sky_positions = None 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) self._skyaper = True self._sky_positions = 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) self._pixel_apertures = apertures # Validate the segmentation-masking inputs up front and keep # the resolved per-aperture source labels for slicing self.segmentation_image = segmentation_image self.labels = labels seg_positions = np.atleast_2d(apertures[0].positions) _, self._seg_labels = process_segmentation_inputs( segmentation_image, labels, mask_method, seg_positions, self._data.shape) # Define output table metadata self.meta = _get_meta() calling_args = (f"method='{method}', subpixels={subpixels}, " f"mask_method='{mask_method}', " f'n_threads={self.n_threads}') self.meta['aperture_photometry_args'] = calling_args self.meta.update(aper_meta) default_columns = ['id', 'x_center', 'y_center'] if self._wcs is not None or self._skyaper: default_columns.append('sky_center') default_columns += ['flux', 'flux_err', 'area', 'flags'] self.default_columns = default_columns def __repr__(self): return make_repr(self, self._repr_params) def __str__(self): return make_repr(self, self._repr_params, long=True) def __getattribute__(self, name): # Collapse the leading position axis of the public array-valued # output attributes to a scalar when a single scalar aperture # position is input (e.g., ``CircularAperture((10, 20), r=5)``). # The scalar conversion is applied centrally here instead of # being repeated on each individual property. Public attributes # that are not per-position outputs are excluded, so that, for # example, the input ``segmentation_image`` is not reduced to # its first row. value = super().__getattribute__(name) if (not name.startswith('_') and name not in _SCALAR_EXCLUDE and isinstance(value, SCALAR_COLLAPSE_TYPES) and self.isscalar): return collapse_scalar_value(value) return value def _array(self, name): """ Return the full (never scalar-collapsed) array for a public output attribute, regardless of whether the instance is scalar. This bypasses the scalar conversion performed in ``__getattribute__`` so that the table-building and flag-decoding machinery always operates on arrays. """ return object.__getattribute__(self, name) @cached_property def isscalar(self): """ Whether the instance is scalar (i.e., a single scalar aperture position such as ``CircularAperture((10, 20), r=5)``, not a length-1 list of positions). """ return self._pixel_apertures[0].isscalar @cached_property def _photometry_results(self): """ The per-aperture photometry result objects, one for each input aperture. """ return [aper._photometry( self._data, error=self._error, mask=self._mask, method=self.method, subpixels=self.subpixels, segmentation_image=self.segmentation_image, labels=self._seg_labels, mask_method=self.mask_method, mask_nonfinite=True, n_threads=self.n_threads) for aper in self._pixel_apertures] @cached_property def _positions(self): """ The aperture positions in pixels, always as a 2D array. """ return np.atleast_2d(self._pixel_apertures[0].positions) @cached_property def n_positions(self): """ The number of aperture positions. """ return self._positions.shape[0] @cached_property def id(self): """ The aperture identification number(s). """ return np.arange(self.n_positions) + 1 @cached_property def x_center(self): """ The ``x`` pixel coordinate(s) of the aperture center(s). """ return self._positions[:, 0] @cached_property def y_center(self): """ The ``y`` pixel coordinate(s) of the aperture center(s). """ return self._positions[:, 1] @cached_property def sky_center(self): """ The sky coordinates of the aperture center(s), or `None` if no ``wcs`` was input. """ if self._skyaper: pos = self._sky_positions if pos.isscalar: # Return a length-1 SkyCoord array return pos.reshape((-1,)) return pos if self._wcs is not None: return self._wcs.pixel_to_world(*np.transpose(self._positions)) return None def _stack(self, attr): """ Stack a per-aperture result attribute into a 1D array (single aperture) or a 2D ``(n_positions, n_apertures)`` array (list of apertures). """ values = [getattr(result, attr) for result in self._photometry_results] if self._single_aperture: return values[0] return np.stack(values, axis=1) @cached_property def flux(self): """ The sum of the (weighted) values within the aperture(s). The values are always float64, regardless of the input ``data`` dtype (a `~astropy.units.Quantity` if ``data`` has units). The value is NaN where the aperture does not overlap the data (the ``'no_overlap'`` flag). """ values = self._stack('flux') if self._data_unit is not None: values <<= self._data_unit return values @cached_property def flux_err(self): """ The uncertainty in the `flux` values. The values are always float64, regardless of the input ``error`` dtype (a `~astropy.units.Quantity` if ``data`` has units). If the input ``error`` is `None`, this is filled with NaN values. """ values = self._stack('flux_err') if self._data_unit is not None: values <<= self._data_unit return values @cached_property def area(self): """ The total unmasked overlap area of the aperture(s) (in ``pix**2``). This takes into account the aperture mask method, masked data pixels (``mask`` keyword), segmentation masking, and partial/no overlap of the aperture with the data. The value is NaN where the aperture does not overlap the data (the ``'no_overlap'`` flag). """ values = [result.area for result in self._photometry_results] if self._single_aperture: return values[0] stacked = np.stack([value.value for value in values], axis=1) return u.Quantity(stacked, u.pix**2) @cached_property def flags(self): """ The bitwise quality flags for the aperture(s). See `~photutils.aperture.decode_aperture_flags` for decoding flag values. """ return self._stack('flags') @deprecated_positional_kwargs(since='3.1', until='4.0') def to_table(self, *, columns=None): """ Create a `~astropy.table.QTable` of the aperture photometry results. Parameters ---------- columns : str, list or tuple of str, or `None`, optional Names of columns, in order, to include in the output `~astropy.table.QTable`. The allowed column names are ``'id'``, ``'x_center'``, ``'y_center'``, ``'sky_center'``, ``'flux'``, ``'flux_err'``, ``'area'``, and ``'flags'``. If ``columns`` is `None`, then a default list of columns will be used (the ``default_columns`` attribute). If a list of apertures was input, then the ``'flux'``, ``'flux_err'``, ``'area'``, and ``'flags'`` columns will have a ``'_i'`` suffix (e.g., ``'flux_0'``), where ``i`` is the index of the aperture in the input list. Returns ------- table : `~astropy.table.QTable` A table of the aperture photometry results with one row per aperture position. Raises ------ ValueError If any name in ``columns`` is not a valid column name, or if ``'sky_center'`` is requested but no WCS was input. """ allowed_columns = ('id', 'x_center', 'y_center', 'sky_center', 'flux', 'flux_err', 'area', 'flags') if columns is None: table_columns = self.default_columns else: table_columns = validate_table_columns(columns, allowed_columns) if ('sky_center' in table_columns and self._array('sky_center') is None): msg = ("the 'sky_center' column requires a WCS, which must " 'be defined by the input data or the wcs keyword') raise ValueError(msg) tbl = QTable() tbl.meta.update(self.meta) per_aperture = ('flux', 'flux_err', 'area', 'flags') for column in table_columns: values = self._array(column) if column in per_aperture and not self._single_aperture: for i in range(len(self._pixel_apertures)): name = f'{column}_{i}' tbl[name] = values[:, i] else: tbl[column] = values return tbl def decode_flags(self, *, return_bit_values=False): """ Decode the aperture quality flags into individual components. This is a convenience method that calls `~photutils.aperture.decode_aperture_flags` with the `flags` attribute. Parameters ---------- return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag names (strings). Returns ------- decoded : dict A dictionary mapping each aperture position `id` to its decoded flags. For a single input aperture, each value is the list of active flag names (or bit values) for that position. If a list of apertures was input (2D `flags`), each value is a list with one entry per aperture, so ``decoded[id][j]`` gives the active flags for position ``id`` and aperture ``j``. The entries follow the position order. See Also -------- photutils.aperture.decode_aperture_flags """ decoded = decode_aperture_flags(self._array('flags'), return_bit_values=return_bit_values) return {int(id_): flags for id_, flags in zip(self._array('id'), decoded, strict=True)} @_update_method_subpixels_docstring @deprecated_positional_kwargs(since='3.0', until='4.0') def aperture_photometry(data, apertures, error=None, mask=None, method='exact', subpixels=5, wcs=None): # numpydoc ignore: PR01,PR02,PR04,PR07 """ Perform aperture photometry on the input data by summing the flux within the given aperture(s). .. note:: This is a legacy function. It is not deprecated, but new features are no longer added to it. The :class:`~photutils.aperture.AperturePhotometry` class is the recommended tool for aperture photometry. It provides additional outputs (e.g., aperture areas, quality flags) and options (e.g., segmentation-based neighbor masking). 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. Nonfinite ``data`` values (NaN and inf) are not masked automatically. An aperture that contains one has a nonfinite sum. 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). A tuple or `~numpy.ndarray` of them is also accepted. 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. 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). .. deprecated:: 3.0 The ``'xcenter'`` and ``'ycenter'`` column names are deprecated aliases of ``'x_center'`` and ``'y_center'``. They emit a deprecation warning when used and will be removed in version 4.0. * ``'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). The values are always float64, regardless of the input ``data`` dtype (a `~astropy.units.Quantity` with float64 values if ``data`` has units). * ``'aperture_sum_err'``: The corresponding uncertainty in the ``'aperture_sum'`` values (always float64). Returned only if the input ``error`` is not `None`. If a list of apertures is input (even a list with one aperture), the ``'aperture_sum'`` and ``'aperture_sum_err'`` columns will have a ``'_i'`` suffix (e.g., ``'aperture_sum_0'``), where ``i`` is the index of the aperture in the input list. The table metadata includes the date, the version numbers of Python, Photutils, and its dependencies, the ``method`` and ``subpixels`` calling arguments, and the class name and shape parameters of each input aperture. Raises ------ ValueError If ``apertures`` contains a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` and no WCS is input, or if the input apertures do not all have identical positions. Notes ----- `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. If the input ``data`` is a `~astropy.nddata.NDData` object, then its ``mask``, ``wcs``, unit, and uncertainty are used. The ``error``, ``mask``, and ``wcs`` keywords are then ignored, and an `~astropy.utils.exceptions.AstropyUserWarning` is issued for each one that is input. The uncertainty is used as ``error`` only if it is a `~astropy.nddata.StdDevUncertainty`. Any other uncertainty type is ignored. """ if isinstance(data, NDData): data, error, mask, wcs = unpack_nddata(data, error, mask, wcs) return aperture_photometry(data, apertures, error=error, mask=mask, method=method, subpixels=subpixels, wcs=wcs) # The aperture-list handling, sky-to-pixel conversion, and # position-equality checking below duplicate the equivalent code in # AperturePhotometry.__init__. The duplication is intentional. This # function is frozen and must not gain new behavior, so it must not # be refactored to share the class logic. 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)) for i, aper in enumerate(apertures): result = aper._photometry( data, error=error, mask=mask, method=method, subpixels=subpixels) # Legacy column names sum_key = 'aperture_sum' sum_err_key = 'aperture_sum_err' if not single_aperture: sum_key += f'_{i}' sum_err_key += f'_{i}' tbl[sum_key] = result.flux if error is not None: tbl[sum_err_key] = result.flux_err return tbl astropy-photutils-24be529/photutils/aperture/polygon.py000066400000000000000000001523751526452531300235500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Polygon apertures in both pixel and sky coordinates. """ from functools import cached_property import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from photutils.aperture._batch_photometry import SHAPE_POLYGON from photutils.aperture.attributes import (ApertureAttribute, PixelPositions, SkyCoordPositions) from photutils.aperture.core import (PixelAperture, SkyAperture, _enable_batch_photometry, _update_method_subpixels_docstring) from photutils.geometry._polygon_overlap import polygon_overlap_grid __all__ = [ 'PolygonAperture', 'SkyPolygonAperture', ] def _signed_polygon_area(verts): """ Calculate the signed area of a polygon (positive for counterclockwise vertices) via the shoelace formula. Parameters ---------- verts : `~numpy.ndarray` ``(n_vertices, 2)`` array of polygon vertices. Returns ------- area : float The signed area of the polygon. """ x = verts[:, 0] y = verts[:, 1] return 0.5 * float(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y)) def _orientation(a, b, c): """ Return twice the signed area of the triangle ``a``, ``b``, ``c``. The result is positive if ``a -> b -> c`` traverses counterclockwise, negative if clockwise, and zero if the three points are collinear. """ return ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) def _on_segment(a, b, c): """ Return `True` if point ``c``, assumed collinear with ``a`` and ``b``, lies within the closed bounding box of the segment ``a b``. """ return (min(a[0], b[0]) <= c[0] <= max(a[0], b[0]) and min(a[1], b[1]) <= c[1] <= max(a[1], b[1])) def _segments_intersect(p1, p2, p3, p4): """ Return `True` if the closed line segments ``p1 p2`` and ``p3 p4`` intersect, including the cases where they merely touch at a point or overlap collinearly. """ d1 = _orientation(p3, p4, p1) d2 = _orientation(p3, p4, p2) d3 = _orientation(p1, p2, p3) d4 = _orientation(p1, p2, p4) # The segments straddle each other (proper crossing). if ((((d1 > 0) and (d2 < 0)) or ((d1 < 0) and (d2 > 0))) and (((d3 > 0) and (d4 < 0)) or ((d3 < 0) and (d4 > 0)))): return True # Collinear / touching endpoints. if d1 == 0.0 and _on_segment(p3, p4, p1): return True if d2 == 0.0 and _on_segment(p3, p4, p2): return True if d3 == 0.0 and _on_segment(p1, p2, p3): return True return bool(d4 == 0.0 and _on_segment(p1, p2, p4)) def _polygon_is_simple(verts): """ Return `True` if the closed polygon defined by ``verts`` is simple (no two nonadjacent edges intersect). Every pair of nonadjacent polygon edges is tested for intersection using orientation predicates, including the case where an endpoint of one edge lies on another edge. Adjacent edges (which share a vertex by construction) are not compared with each other. A polygon that folds straight back onto an adjacent edge is still rejected, because a later nonadjacent edge then touches the folded edge. For a triangle, which has no nonadjacent edges, such a fold has zero area and is excluded by the zero-area check in `_validate_simple_polygon`. The cost is ``O(n^2)`` in the number of vertices ``n``. This is negligible for the polygon sizes used in aperture photometry, and the check is performed only once, when the attribute is set. Parameters ---------- verts : `~numpy.ndarray` ``(n_vertices, 2)`` array of polygon vertices. Returns ------- is_simple : bool `True` if the polygon is simple, `False` otherwise. """ n = verts.shape[0] for i in range(n): a1 = verts[i] a2 = verts[(i + 1) % n] # Compare edge i only with later, nonadjacent edges. Edges i # and i+1 share a vertex, and (for i == 0) edges 0 and n-1 share # a vertex, so those pairs are skipped. for j in range(i + 2, n): if i == 0 and j == n - 1: continue b1 = verts[j] b2 = verts[(j + 1) % n] if _segments_intersect(a1, a2, b1, b2): return False return True def _validate_simple_polygon(verts, *, name='vertex_offsets', check_simple=True): """ Verify that ``verts`` defines a simple, nondegenerate polygon. The polygon is required to be simple (i.e., non-self-intersecting), but it is not required to be convex. Self-intersection is checked explicitly with an ``O(n^2)`` edge-pair test (see `_polygon_is_simple`). This is inexpensive for the polygon sizes used in aperture photometry and runs only once, when the attribute is set. Parameters ---------- verts : `~numpy.ndarray` ``(n_vertices, 2)`` array of polygon vertices. name : str, optional Name to use in error messages. check_simple : bool, optional Whether to run the ``O(n^2)`` self-intersection test. Set to `False` only for offsets that are already known to define a simple polygon (e.g., a circle, ellipse, or rectangle discretized into vertices). The cheap shape, vertex-count, finiteness, and zero-area checks are still performed. Returns ------- verts_ccw : `~numpy.ndarray` The vertices reordered to be counterclockwise. """ if verts.ndim != 2 or verts.shape[1] != 2: msg = (f'{name!r} must have shape (n_vertices, 2), ' f'got {verts.shape}') raise ValueError(msg) n = verts.shape[0] if n < 3: msg = (f'{name!r} must define a polygon with at least 3 ' f'vertices, got {n}') raise ValueError(msg) if not np.all(np.isfinite(verts)): msg = f'{name!r} must not contain any nonfinite values' raise ValueError(msg) signed_area = _signed_polygon_area(verts) if signed_area == 0.0: msg = (f'{name!r} defines a degenerate polygon with zero ' 'area (vertices are collinear or coincident)') raise ValueError(msg) if check_simple: if np.any(np.all(verts == np.roll(verts, -1, axis=0), axis=1)): msg = f'{name!r} must not contain repeated consecutive vertices' raise ValueError(msg) if not _polygon_is_simple(verts): msg = (f'{name!r} must define a simple polygon, but its ' 'edges self-intersect') raise ValueError(msg) if signed_area < 0: verts = verts[::-1].copy() return verts class PixelVertexOffsets(ApertureAttribute): """ Validate and set ``vertex_offsets`` for `PolygonAperture`. The value is converted to an ``(n_vertices, 2)`` `~numpy.ndarray` of pixel offsets and reordered into counterclockwise vertex order. """ def __set__(self, instance, value): value = self._validate(value) if self.name in instance.__dict__: self._reset_cached_properties(instance) instance.__dict__[self.name] = value def _validate(self, value): if isinstance(value, u.Quantity): msg = f'{self.name!r} must not be a Quantity' raise TypeError(msg) try: value = np.asarray(value, dtype=float) except (TypeError, ValueError) as exc: msg = (f'{self.name!r} must be convertible to a numeric ' f'(n_vertices, 2) array') raise TypeError(msg) from exc return _validate_simple_polygon(value, name=self.name) class SkyVertexOffsets(ApertureAttribute): """ Validate and set ``vertex_offsets`` for `SkyPolygonAperture`. The value must be an angular `~astropy.units.Quantity` of shape ``(n_vertices, 2)``. The two columns are the ``(d_lon, d_lat)`` offsets relative to the corresponding ``positions`` coordinate. """ def __set__(self, instance, value): value = self._validate(value) if self.name in instance.__dict__: self._reset_cached_properties(instance) instance.__dict__[self.name] = value def _validate(self, value): if not isinstance(value, u.Quantity): msg = (f'{self.name!r} must be an angular Quantity with ' 'shape (n_vertices, 2)') raise TypeError(msg) if not value.unit.is_equivalent(u.deg): msg = f'{self.name!r} must have angular units' raise u.UnitsError(msg) if value.ndim != 2 or value.shape[1] != 2: msg = (f'{self.name!r} must have shape (n_vertices, 2), ' f'got {value.shape}') raise ValueError(msg) # Validate using arcsec values. Angular geometry on the local # tangent plane is preserved. verts = value.to_value(u.arcsec) verts = _validate_simple_polygon(verts, name=self.name) return (verts * u.arcsec).to(value.unit) def _vertices_centroid(verts): """ Return the area-weighted geometric center of a polygon. For a polygon with vertices ``v_i`` and area ``A``, the centroid is: C_x = (1/(6A)) sum_i (x_i + x_{i+1}) (x_i y_{i+1} - x_{i+1} y_i) C_y = (1/(6A)) sum_i (y_i + y_{i+1}) (x_i y_{i+1} - x_{i+1} y_i) """ x = verts[:, 0] y = verts[:, 1] x1 = np.roll(x, -1) y1 = np.roll(y, -1) cross = x * y1 - x1 * y area = 0.5 * float(np.sum(cross)) cx = float(np.sum((x + x1) * cross)) / (6.0 * area) cy = float(np.sum((y + y1) * cross)) / (6.0 * area) return np.array([cx, cy]) def _wrap_degrees(angle): """ Wrap an angle in degrees to the range ``[0, 360)``. The second modulo maps the value of exactly 360 that the first one returns for a tiny negative angle to 0. """ return (angle % 360.0) % 360.0 def _regular_polygon_offsets(n_vertices, radius, angle_rad): """ Return the ``(n_vertices, 2)`` array of vertex offsets for a regular polygon with the given circumradius and rotation angle. The first vertex sits at angle ``pi/2 + angle_rad`` (i.e., directly above the center for ``angle_rad == 0``), and subsequent vertices are placed counterclockwise at uniform angular spacing. """ base = np.pi / 2.0 + float(angle_rad) step = 2.0 * np.pi / n_vertices thetas = base + np.arange(n_vertices) * step return np.column_stack([radius * np.cos(thetas), radius * np.sin(thetas)]) def _is_regular_polygon(offsets, *, rtol=1.0e-7, atol=1.0e-10): """ Return `True` if ``offsets`` defines a regular polygon centered at the origin (equal vertex distances and uniform angular spacing). """ n = offsets.shape[0] radii = np.hypot(offsets[:, 0], offsets[:, 1]) if not np.allclose(radii, radii[0], rtol=rtol, atol=atol): return False # Sort vertex angles in [0, 2*pi) and check uniform spacing. angles = np.mod(np.arctan2(offsets[:, 1], offsets[:, 0]), 2.0 * np.pi) angles_sorted = np.sort(angles) diffs = np.diff(np.concatenate([angles_sorted, angles_sorted[:1] + 2.0 * np.pi])) expected = 2.0 * np.pi / n return np.allclose(diffs, expected, rtol=rtol, atol=atol) class _RegularPolygonGeometry: """ Compute the geometric properties of a regular polygon from its vertex offsets. This small helper centralizes the regular-polygon math shared by `PolygonAperture` and `SkyPolygonAperture`, avoiding duplicated trigonometric formulas in the two classes. Parameters ---------- offsets : `~numpy.ndarray` The ``(n_vertices, 2)`` array of vertex offsets relative to the polygon center, as plain numbers in the aperture's native length unit (pixels for `PolygonAperture`, arcseconds for `SkyPolygonAperture`). Notes ----- The length properties (`outer_radius`, `inner_radius`, `side_length`) are returned as plain floats in the same length unit as ``offsets``. The angular properties (`interior_angle`, `exterior_angle`, `theta`) are returned as `~astropy.units.Quantity` in degrees and are independent of the length unit. """ def __init__(self, offsets): self.offsets = offsets self.n_vertices = offsets.shape[0] @property def is_regular(self): return _is_regular_polygon(self.offsets) @property def side_length(self): return (2.0 * self.outer_radius * float(np.sin(np.pi / self.n_vertices))) @property def outer_radius(self): return float(np.hypot(self.offsets[0, 0], self.offsets[0, 1])) @property def inner_radius(self): return self.outer_radius * float(np.cos(np.pi / self.n_vertices)) @property def interior_angle(self): return ((self.n_vertices - 2) / self.n_vertices) * 180.0 * u.deg @property def exterior_angle(self): return (360.0 / self.n_vertices) * u.deg @property def theta(self): dx, dy = self.offsets[0] theta_rad = np.arctan2(dy, dx) - np.pi / 2 return _wrap_degrees(np.degrees(theta_rad)) * u.deg @_enable_batch_photometry class PolygonAperture(PixelAperture): """ A polygon aperture defined in pixel coordinates. The aperture has a single fixed shape, defined by ``vertex_offsets`` relative to each ``positions`` entry, but it can have multiple positions. 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 vertex_offsets : array_like Shape ``(n_vertices, 2)``. The pixel-space offsets ``(dx, dy)`` of each polygon vertex relative to ``positions``. The polygon must be simple (non-self-intersecting) with at least 3 vertices. The polygon may be convex or nonconvex. Either clockwise or counterclockwise vertex orderings are accepted. The input is normalized to counterclockwise internally. Raises ------ TypeError : `TypeError` If ``vertex_offsets`` is a `~astropy.units.Quantity` or cannot be converted to a numeric array. ValueError : `ValueError` If ``vertex_offsets`` does not have shape ``(n_vertices, 2)``, contains nonfinite values, or does not define a simple polygon of nonzero area with at least 3 vertices. Examples -------- >>> import numpy as np >>> from photutils.aperture import PolygonAperture >>> # A regular hexagon centered on (10, 20) with circumradius 5. >>> theta = np.linspace(0.0, 2 * np.pi, 6, endpoint=False) >>> offsets = np.column_stack([5.0 * np.cos(theta), ... 5.0 * np.sin(theta)]) >>> aper = PolygonAperture((10.0, 20.0), offsets) Construct a single polygon directly from absolute vertex coordinates: >>> verts = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0)] >>> aper = PolygonAperture.from_vertices(verts) """ _params = ('positions', 'vertex_offsets') positions = PixelPositions('The center pixel position(s).') vertex_offsets = PixelVertexOffsets( 'The (n_vertices, 2) array of pixel offsets of the polygon ' 'vertices, measured relative to ``positions``.') def __init__(self, positions, vertex_offsets): self.positions = positions self.vertex_offsets = vertex_offsets @classmethod def _from_simple_offsets(cls, positions, offsets): """ Construct a `PolygonAperture` from vertex offsets that are known to define a simple polygon, skipping the ``O(n^2)`` self-intersection check. This is a fast-path internal constructor used by the ``to_polygon`` methods of the built-in apertures, where the generated polygon (a discretized circle, ellipse, or rectangle) is guaranteed to be simple by construction. The cheap shape, vertex-count, finiteness, zero-area, and counterclockwise normalization checks are still applied. """ offsets = np.asarray(offsets, dtype=float) offsets = _validate_simple_polygon(offsets, name='vertex_offsets', check_simple=False) obj = cls.__new__(cls) obj.positions = positions obj.__dict__['vertex_offsets'] = offsets return obj @classmethod def from_vertices(cls, vertices): """ Construct a `PolygonAperture` from a single set of absolute pixel vertices. The aperture's ``positions`` is set to the area-weighted centroid of the input vertices, and ``vertex_offsets`` is set to the vertices minus the centroid. Parameters ---------- vertices : array_like Shape ``(n_vertices, 2)`` array of absolute pixel ``(x, y)`` vertex coordinates. Returns ------- aperture : `PolygonAperture` A scalar polygon aperture whose ``vertices`` property reproduces the input vertices. Clockwise input is returned in reversed (counterclockwise) order. Raises ------ ValueError : `ValueError` If ``vertices`` does not have shape ``(n_vertices, 2)`` or does not define a simple polygon of nonzero area with at least 3 finite vertices. """ verts = np.asarray(vertices, dtype=float) if verts.ndim != 2 or verts.shape[1] != 2: msg = ('vertices must have shape (n_vertices, 2), ' f'got {verts.shape}') raise ValueError(msg) # Validate before computing the centroid. The area-weighted # centroid formula divides by the polygon area, which is zero # for a degenerate (collinear or coincident) polygon. The # offsets are a pure translation of the validated vertices, so # the O(n^2) self-intersection check does not need to be # repeated on them. verts = _validate_simple_polygon(verts, name='vertices') center = _vertices_centroid(verts) offsets = verts - center return cls._from_simple_offsets(tuple(center), offsets) @classmethod def from_regular_polygon(cls, positions, n_vertices, radius, *, theta=0.0): """ Construct a regular `PolygonAperture` from a center, vertex count, circumradius, and rotation angle. The first vertex is placed directly above the center (in the ``+y`` direction) for ``theta == 0`` and is rotated counterclockwise by ``theta``. This convention matches `regions.RegularPolygonPixelRegion`. Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) (see `PolygonAperture` for the accepted formats). n_vertices : int The number of vertices (must be at least 3). radius : float The circumradius (outer radius) of the polygon in pixels (must be positive). theta : float or `~astropy.units.Quantity`, optional The rotation angle of the polygon, measured counterclockwise from the positive y-axis. A scalar float is interpreted in radians. The default is 0. Returns ------- aperture : `PolygonAperture` The regular polygon aperture. """ n_vertices = int(n_vertices) if n_vertices < 3: msg = f'n_vertices must be at least 3, got {n_vertices}' raise ValueError(msg) radius = float(radius) if not np.isfinite(radius) or radius <= 0.0: msg = f'radius must be a finite positive number, got {radius}' raise ValueError(msg) if isinstance(theta, u.Quantity): theta_rad = float(theta.to_value(u.rad)) else: theta_rad = float(theta) offsets = _regular_polygon_offsets(n_vertices, radius, theta_rad) return cls(positions, offsets) @classmethod def _from_star(cls, positions, n_spikes, outer_radius, *, inner_radius=None, theta=0.0, optimal_shape=False, collinear_edges=False): """ Construct a star-shaped `PolygonAperture`. This private constructor exists to generate nonconvex simple polygons for internal testing. It is not part of the public API. The star has ``n_spikes`` spikes, with vertices alternating between ``outer_radius`` and ``inner_radius``. The first (outer) vertex is placed directly above the center (in the ``+y`` direction) for ``theta == 0`` and is rotated counterclockwise by ``theta``. Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) (see `PolygonAperture` for the accepted formats). n_spikes : int The number of spikes (must be at least 2). outer_radius : float The distance from the center to the outer (spike) vertices in pixels (must be positive). inner_radius : float, optional The distance from the center to the inner vertices in pixels (must be positive and less than ``outer_radius``). If either ``optimal_shape`` or ``collinear_edges`` is `True`, this parameter is ignored and the inner radius is computed automatically. theta : float or `~astropy.units.Quantity`, optional The rotation angle of the star, measured counterclockwise from the positive y-axis to the first (outer) vertex. A scalar float is interpreted in radians. optimal_shape : bool, optional If `True`, compute ``inner_radius`` automatically to produce a star with naturally-proportioned geometry. The spike angle (the angular width at each point) is set to 180°/``n_spikes``, which is half the exterior angle of a regular ``n_spikes``-gon. This creates a balanced star where the indentations between spikes are symmetric and proportional to the spikes themselves. The formula is: inner_radius = outer_radius / (1 + 2¡cos(Ī€/``n_spikes``)) For a 5-pointed star, this gives a radius ratio of Ά + 1 (where Ά is the golden ratio ≈ 1.618), resulting in the inner_radius ≈ 3.82 for outer_radius = 10. This ratio is aesthetically pleasing and appears naturally in classical star designs. Default is `False`. collinear_edges : bool, optional If `True`, compute ``inner_radius`` automatically to make the two polygon edges adjacent to each spike lie on a single straight line. At ``theta=0``, these edges are horizontal at the sides of the top spike, creating a symmetric _/\\_ profile. When rotated (``theta > 0``), the edges remain collinear but at a different angle. This constraint puts each inner vertex on the straight line joining the two outer vertices on either side of a neighboring spike, giving a sharp, classical star appearance. The formula is: inner_radius = (outer_radius ¡ cos(2Ī€/``n_spikes``) / cos(Ī€/``n_spikes``)) This keyword requires ``n_spikes >= 5``. Default is `False`. Returns ------- aperture : `PolygonAperture` The star-shaped polygon aperture. """ n_spikes = int(n_spikes) if n_spikes < 2: msg = f'n_spikes must be at least 2, got {n_spikes}' raise ValueError(msg) outer_radius = float(outer_radius) if not np.isfinite(outer_radius) or outer_radius <= 0.0: msg = ('outer_radius must be a finite positive number, ' f'got {outer_radius}') raise ValueError(msg) # Determine which automatic mode to use, if any n_auto_modes = sum([optimal_shape, collinear_edges]) if n_auto_modes > 1: msg = 'optimal_shape and collinear_edges cannot both be True' raise ValueError(msg) if optimal_shape: # Compute inner radius to make spike angles = 180°/n_spikes inner_radius = (outer_radius / (1.0 + 2.0 * np.cos(np.pi / n_spikes))) elif collinear_edges: # Compute inner radius to make the polygon edges adjacent # to each spike collinear (lie on the same straight line). # This creates a flat-bottomed indentation between spikes. # At theta=0, these edges are horizontal. When rotated, they # remain collinear but at a different angle. This requires # n_spikes >= 5 because for smaller values the formula # produces a negative or zero inner radius. if n_spikes < 5: msg = ('collinear_edges requires n_spikes >= 5, ' f'got {n_spikes}') raise ValueError(msg) inner_radius = (outer_radius * np.cos(2 * np.pi / n_spikes) / np.cos(np.pi / n_spikes)) else: if inner_radius is None: msg = ('inner_radius must be provided if both optimal_shape ' 'and collinear_edges are False') raise ValueError(msg) inner_radius = float(inner_radius) if not np.isfinite(inner_radius) or inner_radius <= 0.0: msg = ('inner_radius must be a finite positive number, ' f'got {inner_radius}') raise ValueError(msg) if inner_radius >= outer_radius: msg = ('inner_radius must be less than outer_radius ' f'({inner_radius} >= {outer_radius})') raise ValueError(msg) if isinstance(theta, u.Quantity): theta_rad = float(theta.to_value(u.rad)) else: theta_rad = float(theta) # Create star vertices alternating between outer and inner radii. # The star has 2*n_spikes vertices total. # For theta=0, the first (outer) vertex points straight up (+y), # at angle pi/2 from the x-axis. n_vertices = 2 * n_spikes step = 2.0 * np.pi / n_vertices base = np.pi / 2.0 + theta_rad # Create alternating radii: outer, inner, outer, inner, ... radii = np.zeros(n_vertices) radii[::2] = outer_radius # even indices: outer vertices radii[1::2] = inner_radius # odd indices: inner vertices # Place vertices at the appropriate angles thetas = base + np.arange(n_vertices) * step offsets = np.column_stack([radii * np.cos(thetas), radii * np.sin(thetas)]) return cls(positions, offsets) @cached_property def vertices(self): """ The absolute pixel ``(x, y)`` vertices of the polygon at each aperture position. For a scalar aperture, the returned array has shape ``(n_vertices, 2)``. For an aperture with ``n_positions`` positions, the returned array has shape ``(n_positions, n_vertices, 2)``. """ # vertex_offsets has shape (n_v, 2) and positions has shape (..., 2). # Broadcast position to vertices. offsets = self.vertex_offsets if self.isscalar: pos = np.asarray(self.positions, dtype=float) return pos + offsets # positions (n_positions, 2) broadcasts to (n_positions, n_v, 2) return self.positions[:, np.newaxis, :] + offsets[np.newaxis, :, :] @cached_property def _xy_bounds(self): """ The ``(xmin, xmax, ymin, ymax)`` offsets of the polygon's axis-aligned bounding box, relative to ``positions``. """ offsets = self.vertex_offsets return (float(offsets[:, 0].min()), float(offsets[:, 0].max()), float(offsets[:, 1].min()), float(offsets[:, 1].max())) @cached_property def _xy_extents(self): """ Half the extents of the polygon's axis-aligned bounding box. """ xmin, xmax, ymin, ymax = self._xy_bounds return 0.5 * (xmax - xmin), 0.5 * (ymax - ymin) @cached_property def _xy_bbox_offset(self): """ The (x, y) offset of the bounding box center from ``positions``. A polygon's vertices need not be symmetric about ``positions``, so its minimal bounding box is generally not centered on the aperture position. """ xmin, xmax, ymin, ymax = self._xy_bounds return 0.5 * (xmin + xmax), 0.5 * (ymin + ymax) def _batch_shape_params(self): # The vertex offsets are counterclockwise (normalized when the # attribute is set) and flattened as (x0, y0, x1, y1, ...) for # the batch Cython photometry driver. return SHAPE_POLYGON, tuple(self.vertex_offsets.ravel()) @cached_property def area(self): """ The exact geometric area of the aperture shape. """ return abs(_signed_polygon_area(self.vertex_offsets)) @cached_property def perimeter(self): """ The perimeter of the polygon in pixels. The perimeter is computed as the sum of the Euclidean distances between consecutive vertices. """ offsets = self.vertex_offsets diff = np.roll(offsets, -1, axis=0) - offsets return float(np.sum(np.hypot(diff[:, 0], diff[:, 1]))) @cached_property def n_vertices(self): """ The number of vertices of the polygon. """ return int(self.vertex_offsets.shape[0]) @cached_property def _regular_geometry(self): """ Helper that computes the regular-polygon geometric properties from the pixel vertex offsets. """ return _RegularPolygonGeometry(self.vertex_offsets) @cached_property def is_regular(self): """ `True` if the polygon is regular (equal-length sides and equal interior angles, with all vertices at the same distance from ``positions``). """ return self._regular_geometry.is_regular def _check_regular(self, name): if not self.is_regular: msg = (f'{name!r} is only defined for a regular polygon ' 'aperture') raise ValueError(msg) @cached_property def outer_radius(self): """ The outer (circumscribed-circle) radius of the regular polygon in pixels, i.e., the distance from ``positions`` to each vertex. This is also called the `circumradius `_. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('outer_radius') return self._regular_geometry.outer_radius @cached_property def inner_radius(self): """ The inner (inscribed-circle) radius of the regular polygon in pixels, i.e., the distance from ``positions`` to the midpoint of each side. This is also called the `inradius `_. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('inner_radius') return self._regular_geometry.inner_radius @cached_property def side_length(self): """ The side length of the regular polygon, in pixels. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('side_length') return self._regular_geometry.side_length @cached_property def interior_angle(self): """ The interior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('interior_angle') return self._regular_geometry.interior_angle @cached_property def exterior_angle(self): """ The exterior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('exterior_angle') return self._regular_geometry.exterior_angle @cached_property def theta(self): """ The rotation angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. The angle is measured counterclockwise from the positive y-axis to the first stored vertex, in the range ``[0, 360) deg``. This convention matches the ``theta`` parameter of `~PolygonAperture.from_regular_polygon`. Note that the rotation of a regular polygon with ``n`` vertices is defined only modulo ``360 / n`` degrees, because rotating it by its exterior angle reproduces the same shape. The reported value therefore depends on which vertex is stored first, which is not necessarily the first vertex the user input. Clockwise ``vertex_offsets`` are reversed when they are normalized to counterclockwise order. For example, a square specified clockwise reports ``90 deg`` where the identical square specified counterclockwise reports ``0 deg``. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('theta') return self._regular_geometry.theta @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. """ verts_x = np.ascontiguousarray(self.vertex_offsets[:, 0]) verts_y = np.ascontiguousarray(self.vertex_offsets[:, 1]) return polygon_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, verts_x, verts_y, use_exact, subpixels) def _to_patch(self, *, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.Polygon` patch (or list of them) 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) offsets = self.vertex_offsets patches = [mpatches.Polygon(pos + offsets, closed=True, **patch_kwargs) for pos in xy_positions] if self.isscalar: return patches[0] return patches def to_sky(self, wcs): """ Convert the aperture to a `SkyPolygonAperture` object defined in celestial coordinates. The conversion uses ``wcs.pixel_to_world`` directly on the absolute pixel vertex coordinates, evaluated at the first aperture position. 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 : `SkyPolygonAperture` The equivalent sky-coordinate polygon aperture. """ xpos, ypos = np.transpose(self.positions) positions = wcs.pixel_to_world(xpos, ypos) first_pos = np.atleast_2d(self.positions)[0] first_skypos = positions if self.isscalar else positions[0] abs_x = first_pos[0] + self.vertex_offsets[:, 0] abs_y = first_pos[1] + self.vertex_offsets[:, 1] sky_vertices = wcs.pixel_to_world(abs_x, abs_y) d_lon, d_lat = first_skypos.spherical_offsets_to(sky_vertices) offsets = u.Quantity([d_lon, d_lat]).T return SkyPolygonAperture(positions=positions, vertex_offsets=offsets) class SkyPolygonAperture(SkyAperture): """ A polygon aperture defined in sky coordinates. The aperture has a single fixed shape, defined by angular ``vertex_offsets`` relative to each ``positions`` entry, but it can have multiple positions. The angular offsets are applied as spherical offsets from each position (see `~astropy.coordinates.SkyCoord.spherical_offsets_by`). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. vertex_offsets : `~astropy.units.Quantity` Shape ``(n_vertices, 2)`` angular Quantity of polygon vertex offsets ``(d_lon, d_lat)`` relative to ``positions``, applied as spherical offsets from each position. The polygon must be simple (non-self-intersecting) with at least 3 vertices. The polygon may be convex or nonconvex. Either vertex ordering is accepted. The input is normalized internally to counterclockwise order in the ``(d_lon, d_lat)`` plane. Raises ------ TypeError : `TypeError` If ``vertex_offsets`` is not a `~astropy.units.Quantity`. UnitsError : `~astropy.units.UnitsError` If ``vertex_offsets`` does not have angular units. ValueError : `ValueError` If ``vertex_offsets`` does not have shape ``(n_vertices, 2)``, contains nonfinite values, or does not define a simple polygon of nonzero area with at least 3 vertices. Examples -------- >>> import astropy.units as u >>> import numpy as np >>> from astropy.coordinates import SkyCoord >>> from photutils.aperture import SkyPolygonAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> theta = np.linspace(0, 2 * np.pi, 5, endpoint=False) >>> r = 1.0 >>> offsets = np.column_stack([r * np.cos(theta), ... r * np.sin(theta)]) * u.arcsec >>> aper = SkyPolygonAperture(positions, offsets) """ _params = ('positions', 'vertex_offsets') positions = SkyCoordPositions('The center position(s) in sky coordinates.') vertex_offsets = SkyVertexOffsets( 'The (n_vertices, 2) angular Quantity of polygon vertex offsets ' '(d_lon, d_lat) relative to ``positions``.') def __init__(self, positions, vertex_offsets): self.positions = positions self.vertex_offsets = vertex_offsets @classmethod def _from_simple_offsets(cls, positions, offsets): """ Construct a `SkyPolygonAperture` from angular vertex offsets that are known to define a simple polygon, skipping the ``O(n^2)`` self-intersection check. This is a fast-path internal constructor used by the ``to_polygon`` methods of the built-in sky apertures, where the generated polygon (a discretized circle, ellipse, or rectangle) is guaranteed to be simple by construction. The cheap shape, vertex-count, finiteness, zero-area, and counterclockwise normalization checks are still applied. Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The center position(s) in sky coordinates. offsets : `~astropy.units.Quantity` The ``(n_vertices, 2)`` angular Quantity of vertex offsets ``(d_lon, d_lat)`` relative to ``positions``. Returns ------- aperture : `SkyPolygonAperture` The sky polygon aperture. """ verts = offsets.to_value(u.arcsec) verts = _validate_simple_polygon(verts, name='vertex_offsets', check_simple=False) offsets = (verts * u.arcsec).to(offsets.unit) obj = cls.__new__(cls) obj.positions = positions obj.__dict__['vertex_offsets'] = offsets return obj @classmethod def from_vertices(cls, vertices): """ Construct a `SkyPolygonAperture` from a single set of absolute sky vertices. The aperture's ``positions`` is set to an approximate centroid of the input vertices, computed as the mean of the spherical offsets of the vertices from the first vertex. The spherical offsets of the vertices from this centroid average to approximately zero. ``vertex_offsets`` is set to those offsets. Parameters ---------- vertices : `~astropy.coordinates.SkyCoord` A SkyCoord with shape ``(n_vertices,)`` of absolute polygon vertex coordinates. Returns ------- aperture : `SkyPolygonAperture` A scalar sky polygon aperture whose ``vertices`` property reproduces the input vertices. Input that is clockwise in the ``(d_lon, d_lat)`` offset plane is returned in reversed (counterclockwise) order. Raises ------ TypeError : `TypeError` If ``vertices`` is not a one-dimensional `~astropy.coordinates.SkyCoord`. ValueError : `ValueError` If ``vertices`` contains nonfinite values or does not define a simple polygon of nonzero area with at least 3 vertices. """ if not isinstance(vertices, SkyCoord) or vertices.ndim != 1: msg = 'vertices must be a one-dimensional SkyCoord' raise TypeError(msg) if (n_vertices := vertices.shape[0]) < 3: msg = ("'vertices' must define a polygon with at least 3 " f'vertices, got {n_vertices}') raise ValueError(msg) # Provisional centroid: spherical mean approximated via mean of # offsets from the first vertex. ref = vertices[0] d_lon, d_lat = ref.spherical_offsets_to(vertices) center_d_lon = d_lon.mean() center_d_lat = d_lat.mean() center = ref.spherical_offsets_by(center_d_lon, center_d_lat) d_lon, d_lat = center.spherical_offsets_to(vertices) offsets = u.Quantity([d_lon, d_lat]).T return cls(positions=center, vertex_offsets=offsets) @classmethod def from_regular_polygon(cls, positions, n_vertices, radius, *, theta=0.0 * u.deg): """ Construct a regular `SkyPolygonAperture` from a center, vertex count, angular circumradius, and rotation angle. The first vertex is placed in the ``+lat`` direction from the center (North for standard celestial coordinates) for ``theta == 0`` and is rotated toward increasing longitude (East) by ``theta``. Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). n_vertices : int The number of vertices (must be at least 3). radius : `~astropy.units.Quantity` The angular circumradius (outer radius) of the polygon (must be a positive angular Quantity). theta : `~astropy.units.Quantity`, optional The rotation angle of the polygon, measured from the ``+lat`` direction (North) toward increasing longitude (East). This is the same position-angle convention as the ``theta`` parameter of the other sky apertures (e.g., `~photutils.aperture.SkyEllipticalAperture`). Returns ------- aperture : `SkyPolygonAperture` The regular sky polygon aperture. """ n_vertices = int(n_vertices) if n_vertices < 3: msg = f'n_vertices must be at least 3, got {n_vertices}' raise ValueError(msg) if (not isinstance(radius, u.Quantity) or not radius.unit.is_equivalent(u.deg)): msg = 'radius must be an angular Quantity' raise TypeError(msg) radius_arcsec = float(radius.to_value(u.arcsec)) if not np.isfinite(radius_arcsec) or radius_arcsec <= 0.0: msg = f'radius must be a finite positive Quantity, got {radius}' raise ValueError(msg) if (not isinstance(theta, u.Quantity) or not theta.unit.is_equivalent(u.deg)): msg = 'theta must be an angular Quantity' raise TypeError(msg) # The offsets are (d_lon, d_lat), where a counterclockwise # rotation moves North toward West. The sign of theta is # flipped so that it is measured from North toward East. theta_rad = float(theta.to_value(u.radian)) offsets_arcsec = _regular_polygon_offsets(n_vertices, radius_arcsec, -theta_rad) offsets = (offsets_arcsec * u.arcsec).to(radius.unit) return cls(positions=positions, vertex_offsets=offsets) @cached_property def n_vertices(self): """ The number of vertices of the polygon. """ return int(self.vertex_offsets.shape[0]) @cached_property def perimeter(self): """ The angular perimeter of the polygon as a `~astropy.units.Quantity` in arcseconds. The perimeter is computed as the sum of the Euclidean distances between consecutive vertices in the ``(d_lon, d_lat)`` offset plane. """ offsets = self.vertex_offsets.to_value(u.arcsec) diff = np.roll(offsets, -1, axis=0) - offsets total = float(np.sum(np.hypot(diff[:, 0], diff[:, 1]))) return total * u.arcsec @cached_property def _regular_geometry(self): """ Helper that computes the regular-polygon geometric properties from the angular vertex offsets (in arcseconds). """ return _RegularPolygonGeometry( self.vertex_offsets.to_value(u.arcsec)) @cached_property def is_regular(self): """ `True` if the polygon is regular (equal-length sides and equal interior angles, with all vertices at the same angular distance from ``positions``). """ return self._regular_geometry.is_regular def _check_regular(self, name): if not self.is_regular: msg = (f'{name!r} is only defined for a regular polygon ' 'aperture') raise ValueError(msg) @cached_property def outer_radius(self): """ The angular outer (circumscribed-circle) radius of the regular polygon as a `~astropy.units.Quantity` in arcseconds, i.e., the distance from ``positions`` to each vertex. This is also called the `circumradius `_. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('outer_radius') return self._regular_geometry.outer_radius * u.arcsec @cached_property def inner_radius(self): """ The angular inner (inscribed-circle) radius of the regular polygon as a `~astropy.units.Quantity` in arcseconds, i.e., the distance from ``positions`` to the midpoint of each side. This is also called the `inradius `_. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('inner_radius') return self._regular_geometry.inner_radius * u.arcsec @cached_property def side_length(self): """ The angular side length of the regular polygon as a `~astropy.units.Quantity` in arcseconds. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('side_length') return self._regular_geometry.side_length * u.arcsec @cached_property def interior_angle(self): """ The interior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('interior_angle') return self._regular_geometry.interior_angle @cached_property def exterior_angle(self): """ The exterior angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('exterior_angle') return self._regular_geometry.exterior_angle @cached_property def theta(self): """ The rotation angle of the regular polygon as an angular `~astropy.units.Quantity` in degrees. The angle is the position angle of the first stored vertex, measured from the ``+lat`` direction (North) toward increasing longitude (East), in the range ``[0, 360) deg``. This convention matches the ``theta`` parameter of `~SkyPolygonAperture.from_regular_polygon`. Note that the rotation of a regular polygon with ``n`` vertices is defined only modulo ``360 / n`` degrees, because rotating it by its exterior angle reproduces the same shape. The reported value therefore depends on which vertex is stored first, which is not necessarily the first vertex the user input. Clockwise ``vertex_offsets`` are reversed when they are normalized to counterclockwise order. This attribute is only defined for regular polygons. Accessing it for a nonregular polygon raises `ValueError`. """ self._check_regular('theta') # The geometry angle is counterclockwise in the (d_lon, d_lat) # plane, which is from North toward West. theta = self._regular_geometry.theta.to_value(u.deg) return _wrap_degrees(-theta) * u.deg @cached_property def vertices(self): """ The absolute sky vertices of the polygon at each aperture position, as a `~astropy.coordinates.SkyCoord`. For a scalar aperture, the returned SkyCoord has shape ``(n_vertices,)``. For an aperture with ``n_positions`` positions, the returned SkyCoord has shape ``(n_positions, n_vertices)``. """ d_lon = self.vertex_offsets[:, 0] d_lat = self.vertex_offsets[:, 1] if self.isscalar: return self.positions.spherical_offsets_by(d_lon, d_lat) # Broadcast to shape (n_positions, n_vertices). positions = self.positions[:, np.newaxis] d_lon_b = d_lon[np.newaxis, :] d_lat_b = d_lat[np.newaxis, :] return positions.spherical_offsets_by(d_lon_b, d_lat_b) def to_pixel(self, wcs): """ Convert the aperture to a `PolygonAperture` object defined in pixel coordinates. The conversion uses ``wcs.world_to_pixel`` directly on the absolute sky vertex coordinates, evaluated at the first aperture position. 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 : `PolygonAperture` The equivalent pixel-coordinate polygon aperture. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) first_skypos = self.positions if self.isscalar else self.positions[0] first_x = float(np.atleast_1d(xpos)[0]) first_y = float(np.atleast_1d(ypos)[0]) d_lon = self.vertex_offsets[:, 0] d_lat = self.vertex_offsets[:, 1] abs_sky_vertices = first_skypos.spherical_offsets_by(d_lon, d_lat) vx, vy = wcs.world_to_pixel(abs_sky_vertices) offsets = np.column_stack([vx - first_x, vy - first_y]) return PolygonAperture(positions=positions, vertex_offsets=offsets) astropy-photutils-24be529/photutils/aperture/rectangle.py000066400000000000000000001056361526452531300240230ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Rectangular and rectangular-annulus apertures in both pixel and sky coordinates. """ import math from functools import cached_property import astropy.units as u import numpy as np from astropy.coordinates import Angle from photutils.aperture._batch_photometry import (SHAPE_RECTANGLE, SHAPE_RECTANGULAR_ANNULUS) from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, ScalarAngle, ScalarAngleOrValue, SkyCoordPositions) from photutils.aperture.core import (PixelAperture, SkyAperture, _enable_batch_photometry, _RotatableApertureMixin, _update_method_subpixels_docstring) from photutils.aperture.mask import ApertureMask from photutils.aperture.polygon import PolygonAperture, SkyPolygonAperture from photutils.geometry import rectangular_overlap_grid from photutils.utils._deprecation import (deprecated, deprecated_positional_kwargs) from photutils.utils._wcs_helpers import (pixel_shape_to_sky_svd, sky_shape_to_pixel_svd) __all__ = [ 'RectangularAnnulus', 'RectangularAperture', 'RectangularMaskMixin', 'SkyRectangularAnnulus', 'SkyRectangularAperture', ] def _rectangular_polygon_offsets(half_x, half_y, theta): """ Compute the four corner offsets of a rectangle with half-extents ``half_x`` and ``half_y`` along the unrotated first and second coordinate axes, rotated by ``theta`` (in radians). The corners are ordered counterclockwise in the unrotated frame. Parameters ---------- half_x : float Half of the rectangle width along the unrotated first coordinate axis. half_y : float Half of the rectangle height along the unrotated second coordinate axis. theta : float The rotation angle in radians from the unrotated first coordinate axis. Returns ------- offsets : 2D `~numpy.ndarray` The four corner offsets of the rectangle, shape (4, 2). """ corners = np.array([[-half_x, -half_y], [half_x, -half_y], [half_x, half_y], [-half_x, half_y]]) cos_t = np.cos(theta) sin_t = np.sin(theta) rot_mat = np.array([[cos_t, -sin_t], [sin_t, cos_t]]) return corners @ rot_mat.T @deprecated('3.0', until='3.2') class RectangularMaskMixin: # pragma: no cover """ Mixin class to create masks for rectangular or rectangular-annulus aperture objects. """ @_update_method_subpixels_docstring def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- 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, '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 width and height' raise ValueError(msg) masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): ny, nx = bbox.shape mask = rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, w, h, self._theta_rad, use_exact, 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, self._theta_rad, use_exact, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] return masks @staticmethod def _calc_extents(width, height, theta_rad): """ Calculate half of the bounding box extents of a rectangle. """ return _calc_rectangle_extents(width, height, theta_rad) @staticmethod def _lower_left_positions(positions, width, height, theta_rad): """ Calculate lower-left positions from the input center positions. Used for creating a `~matplotlib.patches.Rectangle` patch for the aperture. """ return _calc_lower_left_positions(positions, width, height, theta_rad) def _calc_rectangle_extents(width, height, theta_rad): """ Calculate half of the bounding box extents of a rectangle. """ 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_rad): """ Calculate lower-left positions from the input center positions. Used for creating a `~matplotlib.patches.Rectangle` patch for the aperture. """ 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]) @_enable_batch_photometry class RectangularAperture(_RotatableApertureMixin, 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 not positive. 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.') @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 @cached_property 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_rad) def _batch_shape_params(self): return SHAPE_RECTANGLE, (self.w, self.h, self._theta_rad) @cached_property 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_rad) angle = self.theta.to_value(u.deg) 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 @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. """ return rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.w, self.h, self._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])) sky_w, sky_h, sky_angle = pixel_shape_to_sky_svd( wcs, pixcoord, self.w, self.h, self._theta_rad) width = Angle(sky_w, 'arcsec') height = Angle(sky_h, 'arcsec') return SkyRectangularAperture(positions=positions, w=width, h=height, theta=sky_angle) def to_polygon(self): """ Return a `~photutils.aperture.PolygonAperture` with the four corners of this rectangle as its vertices. Returns ------- aperture : `~photutils.aperture.PolygonAperture` A polygon aperture exactly equivalent to the rectangle. """ offsets = _rectangular_polygon_offsets( 0.5 * self.w, 0.5 * self.h, self.theta.to_value(u.radian)) return PolygonAperture._from_simple_offsets(self.positions, offsets) @_enable_batch_photometry class RectangularAnnulus(_RotatableApertureMixin, 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 : float or `None`, optional 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 the inner width (``w_in``) is not less than the outer width (``w_out``), or if the inner height (``h_in``), when given, is not less than the outer height (``h_out``). ValueError : `ValueError` If any of the widths or heights (``w_in``, ``w_out``, ``h_in``, or ``h_out``) is not positive. 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') _ordered_pairs = (('w_in', 'w_out'), ('h_in', 'h_out')) 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.') @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): 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 self.h_in = h_in self.theta = theta @cached_property def _xy_extents(self): """ The half-width and half-height of the bounding box of the outer rectangle. """ return _calc_rectangle_extents(self.w_out, self.h_out, self._theta_rad) def _batch_shape_params(self): return SHAPE_RECTANGULAR_ANNULUS, (self.w_in, self.h_in, self.w_out, self.h_out, self._theta_rad) @cached_property 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_rad) outer_xy_positions = _calc_lower_left_positions(xy_positions, self.w_out, self.h_out, self._theta_rad) patches = [] angle = self.theta.to_value(u.deg) 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 @_update_method_subpixels_docstring def _compute_overlap(self, edges, nx, ny, use_exact, subpixels): """ Compute the overlap of the aperture on the pixel grid. """ overlap = rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.w_out, self.h_out, self._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, self._theta_rad, use_exact, subpixels) # Clip tiny negative values arising from floating-point noise in # the subtraction. np.maximum(overlap, 0.0, out=overlap) 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. Only the outer rectangle is converted with the WCS. The inner sides are the converted outer sides scaled by the input ratios ``w_in / w_out`` and ``h_in / h_out``, so the inner sides always remain smaller than the outer sides and the inner shape is recovered exactly by the inverse conversion. The annulus has a single rotation angle, which is that of the converted outer rectangle. If the WCS is sheared and the inner aspect ratio differs from the outer one, the inner rectangle of the returned annulus is an approximation of the converted inner rectangle, which would have a different orientation. """ 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])) # Convert the outer rectangle. The inner sides keep their input # ratios to the outer sides, which keeps the annulus valid for # any WCS. sky_w, sky_h, sky_angle = pixel_shape_to_sky_svd( wcs, pixcoord, self.w_out, self.h_out, self._theta_rad) w_out = Angle(sky_w, 'arcsec') h_out = Angle(sky_h, 'arcsec') w_in = w_out * (self.w_in / self.w_out) h_in = h_out * (self.h_in / self.h_out) 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(_RotatableApertureMixin, 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, measured from North (PA=0) toward East. This is counterclockwise on an image displayed with North up and East to the left. North and East are the directions of increasing latitude and longitude in the coordinate frame of ``positions`` (e.g., ICRS or Galactic). When the aperture is converted to pixels with a WCS whose celestial frame differs from that frame, the angle is converted to the equivalent angle in the WCS frame, so the aperture keeps the same orientation on the sky. Raises ------ TypeError : `TypeError` If either width (``w``) or height (``h``) is not a `~astropy.units.Quantity`. ValueError : `ValueError` If either width (``w``) or height (``h``) is not positive. 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] first_pixcoord = tuple(np.atleast_2d(positions)[0]) _, pix_w, pix_h, pix_angle = sky_shape_to_pixel_svd( wcs, skypos, self.w.to_value(u.arcsec), self.h.to_value(u.arcsec), self._theta_rad, pixcoord=first_pixcoord) return RectangularAperture(positions=positions, w=pix_w, h=pix_h, theta=pix_angle) def to_polygon(self): """ Return a `~photutils.aperture.SkyPolygonAperture` with the four corners of this rectangle as its vertices. Returns ------- aperture : `~photutils.aperture.SkyPolygonAperture` A sky polygon aperture exactly equivalent to the rectangle. """ unit = self.w.unit # At theta=0, the width (w) is along +lat (North) and the height # (h) is along +lon, so the unrotated first axis uses the # half-height and the second axis uses the half-width. A # counterclockwise rotation in the (lon, lat) plane moves North # toward West, so the sign of the position angle is flipped. offsets = _rectangular_polygon_offsets( 0.5 * self.h.to_value(unit), 0.5 * self.w.to_value(unit), -self.theta.to_value(u.radian)) * unit return SkyPolygonAperture._from_simple_offsets(self.positions, offsets) class SkyRectangularAnnulus(_RotatableApertureMixin, 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 : scalar `~astropy.units.Quantity` or `None`, optional 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, measured from North (PA=0) toward East. This is counterclockwise on an image displayed with North up and East to the left. North and East are the directions of increasing latitude and longitude in the coordinate frame of ``positions`` (e.g., ICRS or Galactic). When the aperture is converted to pixels with a WCS whose celestial frame differs from that frame, the angle is converted to the equivalent angle in the WCS frame, so the aperture keeps the same orientation on the sky. Raises ------ TypeError : `TypeError` If any of the widths or heights (``w_in``, ``w_out``, ``h_in``, or ``h_out``) is not a `~astropy.units.Quantity`. ValueError : `ValueError` If the inner width (``w_in``) is not less than the outer width (``w_out``), or if the inner height (``h_in``), when given, is not less than the outer height (``h_out``). ValueError : `ValueError` If any of the widths or heights (``w_in``, ``w_out``, ``h_in``, or ``h_out``) is not positive. 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') _ordered_pairs = (('w_in', 'w_out'), ('h_in', 'h_out')) 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): 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 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. Only the outer rectangle is converted with the WCS. The inner sides are the converted outer sides scaled by the input ratios ``w_in / w_out`` and ``h_in / h_out``, so the inner sides always remain smaller than the outer sides and the inner shape is recovered exactly by the inverse conversion. The annulus has a single rotation angle, which is that of the converted outer rectangle. If the WCS is sheared and the inner aspect ratio differs from the outer one, the inner rectangle of the returned annulus is an approximation of the converted inner rectangle, which would have a different orientation. """ xpos, ypos = wcs.world_to_pixel(self.positions) positions = np.transpose((xpos, ypos)) skypos = self.positions if self.isscalar else self.positions[0] first_pixcoord = tuple(np.atleast_2d(positions)[0]) # Convert the outer rectangle. The inner sides keep their input # ratios to the outer sides, which keeps the annulus valid for # any WCS. w_out_arcsec = self.w_out.to_value(u.arcsec) h_out_arcsec = self.h_out.to_value(u.arcsec) _, w_out, h_out, pix_angle = sky_shape_to_pixel_svd( wcs, skypos, w_out_arcsec, h_out_arcsec, self._theta_rad, pixcoord=first_pixcoord) w_in = w_out * (self.w_in.to_value(u.arcsec) / w_out_arcsec) h_in = h_out * (self.h_in.to_value(u.arcsec) / h_out_arcsec) return RectangularAnnulus(positions=positions, w_in=w_in, w_out=w_out, h_out=h_out, h_in=h_in, theta=pix_angle) astropy-photutils-24be529/photutils/aperture/region_converters.py000066400000000000000000000455421526452531300256130ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for converting between `regions.Region` and Aperture objects and for converting `shapely.Polygon` objects to `regions.PolygonPixelRegion` 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.polygon import PolygonAperture, SkyPolygonAperture 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 input is not a `regions.Region` object, or 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 semimajor (``a``) and semiminor (``b``) axes. The ``width`` and ``height`` parameters are mapped to the semimajor (``a``) and semiminor (``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 the WCS longitude axis (PA=90). However, the sky-based apertures define ``theta`` as the position angle, relative to the North celestial pole (PA=0), of the semimajor axis (elliptical apertures) or of the width side (rectangular apertures). Therefore, for sky-based elliptical and rectangular 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` * `~regions.PolygonPixelRegion` |rarr| `~photutils.aperture.PolygonAperture` * `~regions.PolygonSkyRegion` |rarr| `~photutils.aperture.SkyPolygonAperture` 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, PolygonPixelRegion, PolygonSkyRegion, 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))) elif isinstance(region, PolygonPixelRegion): verts = np.stack([region.vertices.x, region.vertices.y], axis=1) aperture = PolygonAperture.from_vertices(verts) elif isinstance(region, PolygonSkyRegion): aperture = SkyPolygonAperture.from_vertices(region.vertices) 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.Region` object is returned only if the input ``aperture`` is scalar (a single position that is not in a list or array). Otherwise, a `regions.Regions` object is returned with one region per position. 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`` is not scalar, then a `regions.Regions` object is returned with one region per position. Raises ------ `TypeError` The input is not an `~photutils.aperture.Aperture` object, or the given `~photutils.aperture.Aperture` object is not supported. Notes ----- The elliptical aperture ``a`` and ``b`` parameters represent the semimajor and semiminor 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 the WCS longitude axis (PA=90). However, the sky-based apertures define ``theta`` as the position angle, relative to the North celestial pole (PA=0), of the semimajor axis (elliptical apertures) or of the width side (rectangular apertures). Therefore, for sky-based elliptical and rectangular 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` * `~photutils.aperture.PolygonAperture` |rarr| `~regions.PolygonPixelRegion` * `~photutils.aperture.SkyPolygonAperture` |rarr| `~regions.PolygonSkyRegion` 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.isscalar: 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` An equivalent `regions.Region` object. """ from regions import (CircleAnnulusPixelRegion, CircleAnnulusSkyRegion, CirclePixelRegion, CircleSkyRegion, EllipseAnnulusPixelRegion, EllipseAnnulusSkyRegion, EllipsePixelRegion, EllipseSkyRegion, PixCoord, PolygonPixelRegion, PolygonSkyRegion, RectangleAnnulusPixelRegion, RectangleAnnulusSkyRegion, RectanglePixelRegion, RectangleSkyRegion) if not aperture.isscalar: 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))) elif isinstance(aperture, PolygonAperture): verts = aperture.vertices region = PolygonPixelRegion( PixCoord(x=verts[:, 0], y=verts[:, 1])) elif isinstance(aperture, SkyPolygonAperture): region = PolygonSkyRegion(aperture.vertices) 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 : `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.region_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-24be529/photutils/aperture/stats.py000066400000000000000000004370251526452531300232150ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating properties of sources defined by an Aperture. """ import inspect import warnings from concurrent.futures import ThreadPoolExecutor from copy import copy, deepcopy from functools import cached_property from itertools import pairwise from typing import NamedTuple import astropy.units as u import numpy as np from astropy.nddata import NDData from astropy.stats import (SigmaClip, biweight_location, biweight_midvariance, mad_std) from photutils.aperture import Aperture, SkyAperture, region_to_aperture from photutils.aperture._batch_photometry import (FLAG_COL_BBOX_CLIPPED, FLAG_COL_MASKED, FLAG_COL_N_PIXELS, FLAG_COL_NONFINITE_DATA, FLAG_COL_NONFINITE_ERROR, FLAG_COL_SEG, FLAG_COL_UNCORRECTED, FLAG_COL_VALID, N_FLAG_COLS, batch_aperture_sums) from photutils.aperture._batch_stats import (batch_aperture_gather, batch_biweight, batch_gini, batch_mad, batch_mean_var, batch_minmax, batch_moments, batch_order_stats, batch_sigma_clip_center, batch_sigma_clip_sum, batch_sort_values) from photutils.aperture._common import (SCALAR_COLLAPSE_TYPES, batch_driver_inputs, collapse_scalar_value, unpack_nddata, validate_array, validate_mask_method) from photutils.aperture._segmentation import (make_segmentation_exclusion, process_segmentation_inputs) from photutils.aperture.core import (PixelAperture, _aperture_metadata, _update_method_subpixels_docstring) from photutils.aperture.flags import (APERTURE_FLAGS, _counts_to_flag_bits, decode_aperture_flags) from photutils.morphology import gini as gini_func from photutils.utils._deprecation import (create_empty_deprecated_qtable, deprecated, deprecated_getattr, deprecated_positional_kwargs) from photutils.utils._misc import _get_meta from photutils.utils._moments import (centroid_from_moments, covariance_determinant, covariance_from_moments, eigvals_from_covariance, image_moments, inertia_tensor_from_moments, is_invalid_covariance, is_singular_covariance, orientation_from_covariance, pixel_to_sky_covariance, regularize_covariance, sky_orientation_from_covariance) from photutils.utils._parameters import validate_table_columns from photutils.utils._quantity_helpers import process_quantities from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = ['ApertureStats'] # Default table columns for the 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', 'flags') # Scale factor that converts the median absolute deviation to a robust # estimate of the standard deviation (1 / scipy.stats.norm.ppf(0.75)). # This must match ``astropy.stats.mad_std`` and the value in # ``photutils.aperture._batch_stats``. _MAD_STD_SCALE = 1.482602218505602 # The maximum total number of aperture bounding-box pixels whose packed # pixel buffers are held in memory at once by the fast batch path. The # packed buffers are sized by the bounding-box areas and need up to 40 # bytes per pixel (while sigma clipping), so this budget corresponds to # about 40 MB. When the input apertures exceed this budget, the sources # are processed in blocks and only the per-source results are kept (see # `ApertureStats._block_edges`). _BLOCK_MAX_PIXELS = 2**20 # The packed-buffer reduction kernels available to # `ApertureStats._block_reduce`, as ``name: (kernel, buffers)``. # The kernel operates on the packed ``'values'``, the per-source # ``'sorted'`` values, or the ``'xy'`` values and cutout coordinates. # The order satisfies ``_BLOCK_DEPENDENCIES``. _BLOCK_REDUCTIONS = { 'minmax': (batch_minmax, 'values'), 'mean_var': (batch_mean_var, 'values'), 'gini': (batch_gini, 'values'), 'order_stats': (batch_order_stats, 'sorted'), 'mad': (batch_mad, 'sorted'), 'biweight': (batch_biweight, 'sorted'), 'moments': (batch_moments, 'xy'), 'moments_central': (batch_moments, 'xy'), } # The reductions whose per-source results are inputs to another # reduction. The biweight kernel needs the median and MAD, and the # central moments need the centroid from the raw moments. _BLOCK_DEPENDENCIES = { 'biweight': ('order_stats', 'mad'), 'moments_central': ('moments',), } # The reductions needed by the `ApertureStats` properties, used by # `ApertureStats.to_table` to calculate all of its columns in a single # pass over the blocks. A property missing from this mapping is still # calculated correctly, in its own pass. _MOMENTS_COLUMNS = ('moments', 'cutout_centroid', 'centroid', 'x_centroid', 'y_centroid', 'sky_centroid', 'sky_centroid_icrs') _MOMENTS_CENTRAL_COLUMNS = ( 'moments_central', 'covariance', 'covariance_eigvals', 'covariance_xx', 'covariance_xy', 'covariance_yy', 'eccentricity', 'ellipse_cxx', 'ellipse_cxy', 'ellipse_cyy', 'ellipticity', 'elongation', 'flags', 'fwhm', 'inertia_tensor', 'orientation', 'sky_orientation', 'semimajor_axis', 'semiminor_axis') _BLOCK_COLUMN_REDUCTIONS = { 'median': 'order_stats', 'mode': 'order_stats', 'mad_std': 'mad', 'biweight_location': 'biweight', 'biweight_midvariance': 'biweight', 'gini': 'gini', **dict.fromkeys(_MOMENTS_COLUMNS, 'moments'), **dict.fromkeys(_MOMENTS_CENTRAL_COLUMNS, 'moments_central'), } # 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', } class _BatchGather(NamedTuple): """ Container for the fast Cython batch-driver results shared by `ApertureStats._fast_gather` and `ApertureStats._fast_sum`. Each instance populates only the fields relevant to its footprint, and the remaining fields are `None` or empty: * center-value gather (``_fast_gather``): ``values``, ``local_x``, ``local_y``, ``starts``, ``counts``, ``overlap``, and ``flag_counts``, plus ``sorted_values`` (the packed ascending-sorted surviving values) when sigma clipping is applied * ``sum_method`` gather (``_fast_sum``): ``sum_aper``, ``var_aper``, ``sum_area``, ``overlap``, and ``flag_counts``, plus ``starts`` for an unmerged gather. The packed member buffers (``sum_values``, ``sum_fracs``, ``sum_errsq``, and ``sum_counts``) hold data only inside the driver call while sigma clipping is applied and are `None` or empty afterward """ values: np.ndarray = None local_x: np.ndarray = None local_y: np.ndarray = None starts: np.ndarray = None counts: np.ndarray = None sum_aper: np.ndarray = None var_aper: np.ndarray = None sum_area: np.ndarray = None overlap: np.ndarray = None sum_values: np.ndarray = None sum_fracs: np.ndarray = None sum_errsq: np.ndarray = None sum_counts: np.ndarray = None flag_counts: np.ndarray = None sorted_values: np.ndarray = None # Public attributes that are never collapsed to a scalar for a scalar # instance because they describe the whole object rather than a single # per-source value (see ``ApertureStats.__getattribute__``). Any new # public attribute that is a list, tuple, ndarray, or SkyCoord but is # not a per-source value must be added here, otherwise a length-1 value # will be silently collapsed to its first element for a scalar instance. _SCALAR_EXCLUDE = frozenset({'default_columns', 'isscalar', 'labels', 'n_positions', 'properties', 'segmentation_image'}) @_update_method_subpixels_docstring class ApertureStats: # numpydoc ignore: PR01,PR02,PR04,PR07 """ 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 class. 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. Nonfinite ``data`` values (NaN and inf) are automatically masked. If ``data`` is an `~astropy.nddata.NDData` object, its ``mask``, ``wcs``, unit, and uncertainty are used. The ``error``, ``mask``, and ``wcs`` keywords are then ignored, and an `~astropy.utils.exceptions.AstropyUserWarning` is issued for each one that is input. The uncertainty is used as ``error`` only if it is a `~astropy.nddata.StdDevUncertainty`. Any other uncertainty type is ignored. 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. Nonfinite ``error`` values (NaN and +/- inf) are not automatically masked, unless they are at the same position of nonfinite 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. Nonfinite 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 pixel weights (the fraction of the pixel area covered by the aperture). This method is used only for calculating the ``sum``, ``sum_err``, ``sum_aper_area``, ``data_sum_cutout``, and ``error_sum_cutout`` properties. All other properties use the "center" aperture mask method. The following methods are available: ddof : int, optional The delta degrees of freedom used when computing the ``var`` and ``std`` properties. The divisor used in the calculation is ``N - ddof``, where ``N`` is the number of unmasked pixels within the aperture. The default is ``ddof=0``, which gives the population variance and standard deviation. Use ``ddof=1`` to obtain the sample (unbiased) variance and standard deviation. This keyword affects only the ``var`` and ``std`` properties. All other properties are unaffected, including the ``mean_err`` and ``median_err`` standard errors, which are always computed using the sample standard deviation. Apertures with ``N <= ddof`` unmasked pixels have an undefined ``var`` and ``std`` and are set to NaN. local_bkg : float, `~numpy.ndarray`, `~astropy.units.Quantity`, or \ `None`, optional 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. A `ValueError` is raised if ``local_bkg`` contains any nonfinite values (NaN or inf). n_threads : int, optional The number of threads to use to gather the aperture pixel values, compute the aperture sums, and reduce the per-source statistics (e.g., the sorts, order statistics, robust estimators, and moments). The default is 1 (no multithreading). When ``n_threads`` > 1, the aperture positions are divided into chunks and processed concurrently. The per-source results are independent, so they are identical to the single-threaded computation. The underlying kernels release the Python global interpreter lock (GIL), so multithreading can significantly speed up the statistics for many aperture positions. Multithreading is used only on the fast batch code path. Otherwise, the computation is serial. 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 rerun `~photutils.aperture.ApertureStats`. All properties other than the sum-related ones described below are calculated using the "center" aperture-mask method, which assigns aperture weights of either 0 or 1, so the ``data`` pixel values are used directly and without weighting. This choice reflects a fundamental limitation because, unlike the mean or variance, order statistics (``min``, ``max``, ``median``) and robust estimators (``mad_std``, ``biweight_location``, ``biweight_midvariance``) have no standard, unambiguous definition for pixels with fractional (partial) aperture weights. Accordingly, these quantities cannot be rigorously computed from a weighted aperture footprint. The input ``sum_method`` and ``subpixels`` keywords are used to determine the aperture-mask method only for the sum-related properties: ``sum``, ``sum_err``, ``sum_aper_area``, ``data_sum_cutout``, and ``error_sum_cutout`` (also listed in the ``SUM_FOOTPRINT_PROPERTIES`` class attribute). All other properties, including ``mean``, ``median``, ``std``, and the morphological properties, always use the "center" aperture-mask method regardless of ``sum_method``. The default is ``sum_method='exact'``, which produces exact aperture-weighted photometry. The centroid and the morphological (shape) properties are calculated from image moments of the ``data`` values, including negative values. This differs from `~photutils.segmentation.SourceCatalog`, which sets negative values to zero for these properties. A source segment contains only pixels above the detection threshold, so its few negative values can safely be ignored. In contrast, the sky pixels within an aperture on background-subtracted data are negative about half of the time. Including them lets the positive and negative noise cancel on average, which keeps the centroid and the shape properties nearly unbiased. Setting them to zero would leave a positive noise pedestal across the aperture that pulls the centroid toward the aperture center and inflates the measured source size. The cost is that the image moments of a faint source can be too noisy to define a shape. If the net flux within the aperture is not positive, the ``'undefined_shape'`` flag is set. If the second-order moments are not positive semidefinite, the covariance-derived shape properties are NaN. The `flags` property reports quality conditions from both the "center"-method footprint and the ``sum_method`` footprint (see the `flags` docstring for the per-bit details). The calculated statistics are always float64, regardless of the input ``data`` dtype (`~astropy.units.Quantity` values with float64 dtype if the input ``data`` has units). The compiled code reads C-contiguous ``data`` and ``error`` arrays directly, without a copy, when they are both float32 or both float64 (or when ``data`` is float32 or float64 and no ``error`` is input). Otherwise they are converted to C-contiguous copies (float32 if every array is a floating-point array of at most 4 bytes per value, and float64 otherwise), which are kept for the lifetime of the `ApertureStats` object. All calculations are performed in float64, so float32 inputs give the same results as the same values input as float64 (to within floating-point rounding). The pixel values within the apertures are gathered into temporary buffers. When the input contains many or large apertures, the sources are processed in blocks so that the size of these buffers does not grow with the number of sources. The results are identical. 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) 149.99080259251238 >>> print(aperstats.y_centroid) 24.97484633000507 >>> print(aperstats.centroid) [149.99080259 24.97484633] >>> print(aperstats.mean, aperstats.median) 47.76300955780609 31.913789514433084 >>> print(aperstats.std) 39.193655383492974 >>> print(aperstats.sum) 9286.709206410273 >>> print(aperstats.sum_aper_area) 201.0619298297468 pix2 >>> # More than one aperture position >>> aper2 = CircularAperture(((150, 25), (90, 60)), 10) >>> aperstats2 = ApertureStats(data, aper2) >>> print(aperstats2.x_centroid) [149.98470724 89.97893946] >>> print(aperstats2.sum) [10177.62548482 36653.97704059] """ # Properties computed using the ``sum_method`` aperture-mask # footprint (set by the ``sum_method``/``subpixels`` keywords). # All other properties use the "center" aperture-mask method. SUM_FOOTPRINT_PROPERTIES = ('sum', 'sum_err', 'sum_aper_area', 'data_sum_cutout', 'error_sum_cutout') # Cached properties that are not per-source sliceable: the packed # gather buffers and their reductions. ``__getitem__`` drops these # from the sliced object, which recomputes them lazily from its # sliced inputs. Any new cached property backed by the packed batch # buffers must be added here, otherwise slicing will attempt to # index the packed buffer per source and fail or corrupt it. _NON_SLICEABLE_CACHES = frozenset({ '_batch_inputs', '_fast_gather', '_fast_sum', '_sorted_values', '_order_stats', '_minmax', '_mean_var', '_mad', '_biweight', '_gini', '_fast_cutouts_center', '_block_edges', '_center_gather_meta'}) def __init__(self, data, aperture, *, error=None, mask=None, wcs=None, sigma_clip=None, sum_method='exact', subpixels=5, ddof=0, local_bkg=None, segmentation_image=None, labels=None, mask_method='none', n_threads=1): if isinstance(data, NDData): data, error, mask, wcs = 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 = validate_array(data, 'data') self._data_unit = unit self._validate_aperture(aperture) aperture_meta = _aperture_metadata(aperture) # use input aperture # Convert region to aperture if necessary if not isinstance(aperture, Aperture): aperture = region_to_aperture(aperture) if isinstance(aperture, SkyAperture) and wcs is None: msg = 'A wcs is required when using a SkyAperture' raise ValueError(msg) self.aperture = aperture data_shape = self._data.shape self._error = validate_array(error, 'error', shape=data_shape) self._mask = validate_array(mask, 'mask', shape=data_shape) if self._mask is not None and self._mask.dtype != bool: msg = 'mask must be a boolean array' raise TypeError(msg) 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 # Validate the mask-method keywords here so that an invalid # value is reported at construction rather than at the first # access of a measured property, far from its cause. validate_mask_method(sum_method, subpixels, method_name='sum_method') self.sum_method = sum_method self.subpixels = subpixels if (isinstance(ddof, bool) or not isinstance(ddof, (int, np.integer)) or ddof < 0): msg = 'ddof must be a nonnegative integer' raise ValueError(msg) self.ddof = ddof if (isinstance(n_threads, bool) or not isinstance(n_threads, (int, np.integer)) or n_threads < 1): msg = 'n_threads must be a positive integer' raise ValueError(msg) self.n_threads = int(n_threads) self._block_max_pixels = _BLOCK_MAX_PIXELS # The per-source results calculated by `_block_reduce`, keyed by # reduction name. The dict is created here, not lazily, so that # threads calculating different properties concurrently share # the same dict (a lazily created dict could be replaced by a # second thread before the first stored its results in it). self._block_cache = {} self._local_bkg = np.zeros(self.n_positions) # 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_positions): 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_positions) if np.any(~np.isfinite(local_bkg)): msg = ('local_bkg must not contain any nonfinite ' '(e.g., inf or NaN) values') raise ValueError(msg) self._local_bkg = local_bkg # always an iterable self._ids = np.arange(self.n_positions) + 1 self.default_columns = list(_DEFAULT_COLUMNS) self.meta = _get_meta() self.meta.update(aperture_meta) # Validate the segmentation-masking inputs and resolve the # per-aperture source labels. self.segmentation_image = segmentation_image self.labels = labels self.mask_method = mask_method seg_positions = np.atleast_2d(self._pixel_aperture.positions) (self._segmentation, self._seg_labels) = process_segmentation_inputs( segmentation_image, labels, mask_method, seg_positions, self._data.shape) @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 @property def _cached_properties(self): """ A list of all class cached properties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_properties_cache' # Subclasses get their own cached-property list if attr not in cls.__dict__: def is_cached_property(obj): return isinstance(obj, cached_property) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=is_cached_property)]) return getattr(cls, attr) @property def properties(self): """ A sorted list of the names of the built-in per-source properties. The ``id`` property is not included. """ cached_properties = [name for name in self._cached_properties if not name.startswith('_')] # isscalar and n_positions are scalar values for the whole # object, not per-source values, so they are not valid table # columns cached_properties.remove('isscalar') cached_properties.remove('n_positions') cached_properties.sort() return cached_properties 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', 'ddof', 'n_threads', '_block_max_pixels', 'default_columns', 'meta', '_segmentation', 'segmentation_image', 'mask_method') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) newcls._block_cache = {} # aperture determines isscalar (needed below) newcls.aperture = self.aperture[index] # Keep _ids as a 1D array so a scalar instance's id is always # backed by a length-1 iterable (see the id property). newcls._ids = np.atleast_1d(self._ids[index]) # Slice the per-aperture segmentation labels. Both the input # ``labels`` and the resolved ``_seg_labels`` have one entry per # aperture, so the sliced object reports the labels of the # apertures it actually contains. if self.labels is None: newcls.labels = None else: newcls.labels = np.atleast_1d(self.labels)[index] if self._seg_labels is None: newcls._seg_labels = None else: newcls._seg_labels = np.atleast_1d(self._seg_labels[index]) # Slice evaluated cached-property objects. The instance dict is # copied into the set in one step, not iterated through a keys # view, because another thread may add a cached property to it # at the same time. keys = set(self.__dict__) & set(self._cached_properties) keys.add('_local_bkg') # iterable defined in __init__ # The packed gather buffers and their reductions are not # per-source sliceable. The sliced object recomputes them # lazily from its sliced inputs. keys -= self._NON_SLICEABLE_CACHES for key in keys: value = self.__dict__[key] # Do not insert attributes that are always scalar (e.g., # isscalar, n_positions), 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__}>' return f'{cls_name}\nLength: {self.n_positions}' 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_positions 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') def __getattribute__(self, name): # Collapse the leading position axis of the public per-source # output attributes to a scalar when a single scalar aperture # position is input (e.g., ``CircularAperture((10, 20), r=5)``). # The scalar conversion is applied centrally here instead of # being repeated on each individual property (see ``_array`` for # the array-preserving accessor used by the table-building and # flag-decoding machinery). value = super().__getattribute__(name) if (not name.startswith('_') and name not in _SCALAR_EXCLUDE and isinstance(value, SCALAR_COLLAPSE_TYPES) and self.isscalar): return collapse_scalar_value(value) return value def _array(self, name): """ Return the per-source attribute ``name`` with a leading (source) axis, so that the table-building and flag-decoding machinery always operates on arrays regardless of whether the instance is scalar. For a nonscalar instance this returns the attribute unchanged. For a scalar instance the value is first read through the normal attribute access, which applies the scalar collapse performed by `__getattribute__` (e.g., ``moments`` becomes a ``(4, 4)`` array and ``centroid`` a length-2 array). A single leading length-1 axis is then restored (``ndarray`` values are reshaped, while other objects are wrapped in a length-1 list). Reading the collapsed value rather than the raw stored value is important. A per-source value can be stored either with its leading length-1 axis intact (when computed directly for a scalar instance) or with that axis already removed (when a multisource value is cached and then sliced to a scalar via ``__getitem__``). Going through the scalar collapse normalizes both cases to the same shape, so this method is robust regardless of how the value was produced. """ value = getattr(self, name) if not self.isscalar: return value if isinstance(value, np.ndarray): return value[np.newaxis, ...] return [value] @cached_property 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) @cached_property def _null_object(self): """ Return `None` values. """ return np.array([None] * self.n_positions) @cached_property def _null_value(self): """ Return np.nan values. """ values = np.empty(self.n_positions) values.fill(np.nan) return values @property def id(self): """ The aperture identification number(s). """ return self._ids @property @deprecated('3.1', alternative="the 'id' attribute", until='4.0') def ids(self): """ The aperture identification number(s). """ return self.id 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. Raises ------ TypeError If this is a scalar `ApertureStats` object, which cannot be indexed. ValueError If the input ID number is not a valid source 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 : int, 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. Raises ------ TypeError If this is a scalar `ApertureStats` object, which cannot be indexed. ValueError If any input ID number is not a valid source ID number. """ if self.isscalar: msg = (f'A scalar {self.__class__.__name__!r} object cannot ' 'be indexed') raise TypeError(msg) for id_num in np.atleast_1d(id_nums): if id_num not in self._array('id'): 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 ``'id'`` and the names in the `properties` attribute, except for the 2D cutout properties (``data_cutout``, ``data_sum_cutout``, and ``error_sum_cutout``). 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 source properties with one row per source. Raises ------ ValueError If any name in ``columns`` is not a valid (or deprecated) column name. """ if columns is None: table_columns = self.default_columns else: # id is not included in self.properties because it is not # a cached property allowed_columns = set(self.properties) | set(self.default_columns) # The deprecated inertia_tensor is not a cached property allowed_columns.add('inertia_tensor') # Remove 2D cutout images from the allowed columns allowed_columns = {col for col in allowed_columns if '_cutout' not in col} deprecated_names = _DEPRECATED_ATTRIBUTES.copy() # These are not valid column names, but are deprecated # attributes that are still accessible in 3.x. They will be # removed in 4.0. invalid = ('data_sumcutout', 'error_sumcutout', 'get_id', 'get_ids') for name in invalid: deprecated_names.pop(name) table_columns = validate_table_columns( columns, allowed_columns, deprecated_names=deprecated_names) # 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 if self._block_edges is not None: # Calculate the reductions needed by all of the columns in a # single pass over the blocks. Columns that do not use the # center gather (e.g., 'sum') do not start a pass. reductions = {_BLOCK_COLUMN_REDUCTIONS[column] for column in table_columns if column in _BLOCK_COLUMN_REDUCTIONS} if reductions: self._block_reduce(reductions) for column in table_columns: values = getattr(self, column) # Column assignment requires an object with a length if self.isscalar: values = (values,) # Use the canonical (nondeprecated) column name so that # assigning into ``tbl`` does not trigger a second, # redundant deprecation warning (the deprecated attribute # access above already warned once). canonical_column = _DEPRECATED_ATTRIBUTES.get(column, column) tbl[canonical_column] = values return tbl @cached_property def n_positions(self): """ The number of positions for the input aperture. """ if self.isscalar: return 1 return len(self._pixel_aperture) @property @deprecated('3.1', alternative="the 'n_positions' attribute", until='4.0') def n_apertures(self): """ The number of positions for the input aperture. """ return self.n_positions @cached_property 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 @cached_property def _batch_inputs(self): """ The validated inputs for the fast Cython batch driver, or `None`. Returns an ``(inputs, local_bkg, clip_spec)`` tuple of the driver inputs shared by the center-value and ``sum_method`` gathers (a `~photutils.aperture._common.BatchInputs`, see `_fast_gather` and `_fast_sum`), the contiguous local background values, and the fast sigma-clip specification (`None` when no clipping is performed). `None` is returned (and the mask-based code path is used) when the aperture or inputs are not supported by the batch driver, for example when an unsupported ``sigma_clip`` is used, the aperture does not opt in to the batch driver, or the data/mask dtypes are not supported. The set of supported inputs matches `~photutils.aperture.PixelAperture._batch_photometry`. """ # A non-None sigma_clip is only supported when it maps to the # fast clipping kernel. Otherwise fall back to the mask path. clip_spec = self._fast_clip_spec() if self.sigma_clip is not None and clip_spec is None: return None # The overlap method of the inputs is the ``sum_method``. The # center-value gather always uses the "center" method. inputs = batch_driver_inputs( self._pixel_aperture, self._data, error=self._error, mask=self._mask, method=self.sum_method, subpixels=self.subpixels, segmentation=self._segmentation, labels=self._seg_labels, mask_method=self.mask_method) if inputs is None: return None local_bkg = np.ascontiguousarray(self._local_bkg, dtype=np.float64) return inputs, local_bkg, clip_spec @cached_property def _fast_gather(self): """ The fast Cython "center"-method value gather, or `None`. When supported, this walks each aperture bounding box once and returns a packed buffer of the unmasked "center"-method pixel values (and their cutout coordinates). The higher-tier statistics (order statistics and image moments) are reduced lazily from this buffer. The ``sum_method`` aperture sum, error variance, and area are computed separately and lazily by `_fast_sum`, so they are not paid for unless requested. When a supported ``sigma_clip`` is set, the packed center buffer is sigma-clipped per source before being returned, so the downstream reductions operate on the clipped data transparently. `None` is returned (and the mask-based code path is used) when the fast batch driver is unavailable (see `_batch_inputs`). The result is a `_BatchGather` with the center-value fields (``values``, ``local_x``, ``local_y``, ``starts``, ``counts``), ``overlap``, and ``flag_counts`` populated. The ``sum_method`` fields are `None`. When ``n_threads`` > 1, the positions are divided into chunks that are gathered (and sigma clipped) concurrently and then merged (see `_merge_center_gathers`). """ if self._batch_inputs is None: return None return self._gather_center(slice(None)) def _gather_center(self, index): """ Gather (and sigma clip) the packed "center"-method buffers for the sources selected by the slice ``index``. See `_fast_gather` for the returned `_BatchGather`. """ inputs, local_bkg, clip_spec = self._batch_inputs positions, local_bkg, labels_arr = self._index_sources( index, inputs.positions, local_bkg, inputs.labels) # The batch kernels always exclude nonfinite ``data`` values # (``mask_nonfinite=1``). This matches the mask-based path, # which masks nonfinite data before any segmentation # correction. def gather_chunk(pos, bkg, labels): (values, lx, ly, starts, counts, overlap, flag_counts) = batch_aperture_gather( inputs.data, inputs.mask, pos, inputs.shape_code, inputs.params, inputs.ext_x, inputs.ext_y, inputs.off_x, inputs.off_y, bkg, inputs.segmentation, labels, inputs.seg_method, mask_nonfinite=1) gather = _BatchGather(values=values, local_x=lx, local_y=ly, starts=starts, counts=counts, overlap=overlap, flag_counts=flag_counts) if clip_spec is not None: gather = self._apply_center_clip(gather, clip_spec) return gather chunks = self._batch_chunks(positions, local_bkg, labels_arr) if chunks is None: return gather_chunk(positions, local_bkg, labels_arr) with ThreadPoolExecutor(max_workers=len(chunks[0])) as executor: gathers = list(executor.map(gather_chunk, *chunks)) return self._merge_center_gathers(gathers) @cached_property def _fast_sum(self): """ The fast Cython ``sum_method`` aperture gather, or `None`. When supported, this walks each aperture bounding box once and returns the ``sum_method`` aperture sum, error variance, and area. This is computed only when the ``sum``, ``sum_err``, ``sum_aper_area``, or ``flags`` properties are requested, so the more expensive exact/subpixel overlap fractions are not evaluated for the value-statistics-only use cases. When a supported ``sigma_clip`` is set, the packed ``sum_method`` members are sigma-clipped per source and the aperture sum, variance, and area are recomputed over the survivors. `None` is returned (and the mask-based code path is used) when the fast batch driver is unavailable (see `_batch_inputs`). The result is a `_BatchGather` with the ``sum_aper``, ``var_aper``, ``sum_area``, ``starts``, ``overlap``, and ``flag_counts`` fields populated. The center-value fields are `None`. When ``n_threads`` > 1 or the sources are processed in more than one block (see `_block_edges`), the per-chunk results are merged and only the flat per-source outputs are kept (see `_merge_sum_gathers`), so ``starts`` is `None`. """ if self._batch_inputs is None: return None edges = self._block_edges if len(edges) == 2: return self._gather_sum(slice(None)) # Only the flat per-source outputs are kept from each block return self._merge_sum_gathers( [self._gather_sum(slice(i0, i1)) for i0, i1 in pairwise(edges)]) def _gather_sum(self, index): """ Gather (and sigma clip) the ``sum_method`` aperture sums for the sources selected by the slice ``index``. See `_fast_sum` for the returned `_BatchGather`. """ inputs, local_bkg, clip_spec = self._batch_inputs positions, local_bkg, labels_arr = self._index_sources( index, inputs.positions, local_bkg, inputs.labels) emit_sum = 1 if clip_spec is not None else 0 def sum_chunk(pos, bkg, labels): # The gather path derives its outside-weight flag elsewhere, # so the driver's per-source indicator is not used here. result = batch_aperture_sums( inputs.data, inputs.error, inputs.mask, pos, inputs.shape_code, inputs.params, inputs.ext_x, inputs.ext_y, inputs.off_x, inputs.off_y, inputs.use_exact, inputs.subpixels, inputs.segmentation, labels, inputs.seg_method, bkg, emit_sum, mask_nonfinite=1) (sums, sum_var, area, overlap, starts, sum_values, sum_fracs, sum_errsq, scounts, flag_counts) = ( result.sums, result.sum_vars, result.areas, result.overlap, result.starts, result.sum_values, result.sum_fracs, result.sum_errsq, result.sum_counts, result.flag_counts) gather = _BatchGather(starts=starts, sum_aper=sums, var_aper=sum_var, sum_area=area, overlap=overlap, sum_values=sum_values, sum_fracs=sum_fracs, sum_errsq=sum_errsq, sum_counts=scounts, flag_counts=flag_counts) if clip_spec is not None: gather = self._apply_sum_clip(gather, clip_spec) return gather chunks = self._batch_chunks(positions, local_bkg, labels_arr) if chunks is None: return sum_chunk(positions, local_bkg, labels_arr) with ThreadPoolExecutor(max_workers=len(chunks[0])) as executor: gathers = list(executor.map(sum_chunk, *chunks)) return self._merge_sum_gathers(gathers) @staticmethod def _index_sources(index, positions, local_bkg, labels_arr): """ Apply the slice ``index`` to the per-source batch-driver inputs. Row slices of the C-contiguous input arrays are themselves C-contiguous, so they can be passed directly to the Cython drivers. """ if labels_arr is not None: labels_arr = labels_arr[index] return positions[index], local_bkg[index], labels_arr @cached_property def _block_edges(self): """ The source-index edges of the blocks used to bound the memory of the fast batch path, or `None` when the fast batch driver is unavailable (see `_batch_inputs`). The packed gather buffers hold every pixel of every aperture, so their size grows with the number of sources. When the total aperture bounding-box area (clipped to the data) exceeds the ``_BLOCK_MAX_PIXELS`` budget, the sources are divided into consecutive blocks that each fit the budget (a single source larger than the budget gets its own block). The blocks are then gathered, reduced to per-source results, and discarded one at a time (see `_block_reduce`), so the packed buffers for all of the sources are never held at once. When all of the sources fit in a single block, the edges are ``[0, n_sources]`` and the packed buffers are gathered once and cached (see `_fast_gather`). """ if self._batch_inputs is None: return None bounds = self._pixel_aperture._bbox_bounds ny, nx = self._data.shape width = np.minimum(bounds[:, 1], nx) - np.maximum(bounds[:, 0], 0) height = np.minimum(bounds[:, 3], ny) - np.maximum(bounds[:, 2], 0) n_pixels = np.cumsum(np.clip(width, 0, None).astype(np.intp) * np.clip(height, 0, None)) n_sources = len(n_pixels) edges = [0] while edges[-1] < n_sources: start = edges[-1] offset = n_pixels[start - 1] if start > 0 else 0 end = np.searchsorted(n_pixels, offset + self._block_max_pixels, side='right') edges.append(max(int(end), start + 1)) if n_sources == 0: edges.append(0) # a single empty block return edges def _block_reduce(self, names): """ Calculate the per-source reductions ``names`` of the packed center buffers, block by block, and store them in `_block_cache`. When there is more than one block (see `_block_edges`), the packed buffers of each block of sources are gathered (and sigma clipped), reduced, and then discarded, so the peak memory is set by the block size instead of by the number of sources. A later request for another reduction must then gather all of the blocks again, so each pass also calculates the inexpensive reductions that are commonly requested later. These are the mean and variance, and either the order statistics (when the sorted values are available) or the minimum and maximum. When there is a single block, the reductions are calculated from the cached `_fast_gather` and `_sorted_values` buffers, so only the requested reductions are calculated. Reductions that depend on other reductions (see ``_BLOCK_DEPENDENCIES``) are calculated from the values of the same block. The pixel counts, overlap, and flag counts are always kept. Parameters ---------- names : iterable of str Keys of ``_BLOCK_REDUCTIONS``. The ``'meta'`` result, the ``(counts, overlap, flag_counts)`` tuple, is always calculated. """ cache = self._block_cache edges = self._block_edges single_block = len(edges) == 2 names = set(names) if not single_block: names.add('mean_var') for key in _BLOCK_REDUCTIONS: # in dependency order if key in names: names.update(_BLOCK_DEPENDENCIES.get(key, ())) # Sigma clipping returns the sorted surviving values. clipped = self._batch_inputs[-1] is not None # The cache is tested by key, never iterated, because another # thread may add results to it at the same time. need_sorted = any(_BLOCK_REDUCTIONS[key][1] == 'sorted' for key in names if key not in cache) if not single_block: names.add('order_stats' if clipped or need_sorted else 'minmax') names = [key for key in _BLOCK_REDUCTIONS if key in names and key not in cache] parts = {key: [] for key in names} meta_parts = [] for i0, i1 in pairwise(edges): if single_block: gather = self._fast_gather sorted_values = (self._sorted_values[0] if need_sorted else None) else: gather = self._gather_center(slice(i0, i1)) sorted_values = gather.sorted_values if sorted_values is None and need_sorted: sorted_values = self._threaded_reduction( batch_sort_values, (gather.values,), gather.starts, gather.counts) starts, counts = gather.starts, gather.counts meta_parts.append((counts, gather.overlap, gather.flag_counts)) buffers = { 'values': (gather.values,), 'sorted': (sorted_values,), 'xy': (gather.values, gather.local_x, gather.local_y)} block = {} index = slice(i0, i1) for key in names: func, kind = _BLOCK_REDUCTIONS[key] per_source = () if key == 'biweight': per_source = ( self._block_dependency(block, 'order_stats', index)[2], self._block_dependency(block, 'mad', index)) elif key == 'moments': zeros = np.zeros(i1 - i0) per_source = (zeros, zeros) elif key == 'moments_central': # No-overlap sources have NaN moments and centroids # (see the moments property) moments = self._block_dependency(block, 'moments', index).copy() moments[~np.asarray(gather.overlap, dtype=bool)] = np.nan centroid = centroid_from_moments(moments) per_source = (np.ascontiguousarray(centroid[:, 0]), np.ascontiguousarray(centroid[:, 1])) block[key] = self._threaded_reduction( func, buffers[kind], starts, counts, per_source=per_source) parts[key].append(block[key]) # Free this block's packed buffers before gathering the next del gather, sorted_values, buffers parts['meta'] = meta_parts for key, results in parts.items(): if single_block: cache[key] = results[0] elif isinstance(results[0], tuple): cache[key] = tuple(np.concatenate(arrays) for arrays in zip(*results, strict=True)) else: cache[key] = np.concatenate(results) def _block_dependency(self, block, key, index): """ Return the per-source reduction ``key`` for the block of sources selected by the slice ``index``. A dependency is either calculated in the current pass, and then found in ``block``, or was cached for all of the sources by an earlier pass (see `_block_cache`). """ if key in block: return block[key] value = self._block_cache[key] if isinstance(value, tuple): return tuple(arr[index] for arr in value) return value[index] def _block_result(self, name): """ Return the per-source reduction ``name`` (a key of ``_BLOCK_REDUCTIONS`` or ``'meta'``), calculating it block by block if needed (see `_block_reduce`). """ cache = self._block_cache if name not in cache: self._block_reduce(() if name == 'meta' else (name,)) return cache[name] @cached_property def _center_gather_meta(self): """ The per-source ``(counts, overlap, flag_counts)`` arrays of the center-value gather, or `None` when the fast path is unavailable. """ if self._batch_inputs is None: return None return self._block_result('meta') def _batch_chunks(self, positions, local_bkg, labels_arr): """ Split the per-source batch-driver inputs into per-thread chunks, or return `None` for a single-chunk (serial) computation. The number of chunks is ``min(n_threads, n_sources)``. Row slices of the C-contiguous input arrays are themselves C-contiguous, so the chunks can be passed directly to the Cython drivers. Returns ------- chunks : tuple of list or `None` A ``(positions, local_bkg, labels)`` tuple of per-chunk lists, or `None` when only one chunk would be used. """ n_chunks = min(self.n_threads, positions.shape[0]) if n_chunks <= 1: # 0 for empty positions return None pos_chunks = np.array_split(positions, n_chunks) bkg_chunks = np.array_split(local_bkg, n_chunks) if labels_arr is None: labels_chunks = [None] * n_chunks else: labels_chunks = np.array_split(labels_arr, n_chunks) return pos_chunks, bkg_chunks, labels_chunks @staticmethod def _merge_center_gathers(gathers): """ Merge per-chunk center-value gathers into a single `_BatchGather` equivalent to a single-chunk gather. The packed buffers (``values``, ``local_x``, ``local_y``, and ``sorted_values`` when sigma clipping is applied) and the per-source arrays are concatenated. The per-source ``starts`` are offset by the cumulative packed-buffer length of the preceding chunks. """ starts = [] offset = 0 for gather in gathers: starts.append(gather.starts + offset) offset += gather.values.shape[0] sorted_values = None if gathers[0].sorted_values is not None: sorted_values = np.concatenate( [gather.sorted_values for gather in gathers]) return _BatchGather( values=np.concatenate([g.values for g in gathers]), local_x=np.concatenate([g.local_x for g in gathers]), local_y=np.concatenate([g.local_y for g in gathers]), starts=np.concatenate(starts), counts=np.concatenate([g.counts for g in gathers]), overlap=np.concatenate([g.overlap for g in gathers]), flag_counts=np.concatenate([g.flag_counts for g in gathers]), sorted_values=sorted_values) @staticmethod def _merge_sum_gathers(gathers): """ Merge per-chunk ``sum_method`` gathers into a single `_BatchGather`. Only the flat per-source outputs are kept. The packed member buffers and their ``starts`` are dropped (`None`). They are consumed within each chunk (by the sigma clipping) and no downstream consumer reads them from the merged result. """ return _BatchGather( sum_aper=np.concatenate([g.sum_aper for g in gathers]), var_aper=np.concatenate([g.var_aper for g in gathers]), sum_area=np.concatenate([g.sum_area for g in gathers]), overlap=np.concatenate([g.overlap for g in gathers]), flag_counts=np.concatenate([g.flag_counts for g in gathers])) def _threaded_reduction(self, func, buffers, starts, counts, per_source=()): """ Run a per-source packed-buffer reduction, dividing the sources into chunks that are processed concurrently. The kernel is called as ``func(*buffers, starts, counts, *per_source)``. When ``n_threads`` > 1, the sources are divided into contiguous ranges. Each worker receives the corresponding packed-buffer regions (with the ``starts`` rebased to the region) and per-source array slices, so the kernels run concurrently on disjoint data. The per-chunk outputs are concatenated, making the result identical to the single-chunk call. A kernel returning a packed buffer (e.g., ``batch_sort_values``) also concatenates correctly because the chunk regions partition the buffer in order. Parameters ---------- func : callable The reduction kernel. buffers : tuple of `~numpy.ndarray` The packed per-pixel buffers (equal lengths), passed as the leading kernel arguments. starts, counts : `~numpy.ndarray` The per-source start offsets into the buffers and the per-source pixel counts. per_source : tuple of `~numpy.ndarray`, optional Additional per-source arrays, passed as the trailing kernel arguments. Returns ------- result : `~numpy.ndarray` or tuple of `~numpy.ndarray` The kernel output(s), concatenated over the chunks. """ n_src = len(counts) n_chunks = min(self.n_threads, n_src) if n_chunks <= 1: # 0 for empty positions return func(*buffers, starts, counts, *per_source) buffer_len = len(buffers[0]) src_edges = np.arange(n_chunks + 1) * n_src // n_chunks def reduce_chunk(i0, i1): buf0 = starts[i0] buf1 = starts[i1] if i1 < n_src else buffer_len chunk_buffers = [buffer[buf0:buf1] for buffer in buffers] return func(*chunk_buffers, starts[i0:i1] - buf0, counts[i0:i1], *[arr[i0:i1] for arr in per_source]) with ThreadPoolExecutor(max_workers=n_chunks) as executor: results = list(executor.map(reduce_chunk, src_edges[:-1], src_edges[1:])) if isinstance(results[0], tuple): return tuple(np.concatenate(parts) for parts in zip(*results, strict=True)) return np.concatenate(results) def _fast_clip_spec(self): """ The fast sigma-clip parameters, or `None`. Returns ``(sigma_lower, sigma_upper, maxiters, cenfunc_code, stdfunc_code)`` when ``sigma_clip`` is a `SigmaClip` instance supported by the fast clipping kernel, and `None` otherwise (in which case the mask-based path is used). The fast path supports the string ``cenfunc`` values 'median'/'mean'/'biweight', the string ``stdfunc`` values 'std'/'mad_std'/'biweight', and no spatial growing. """ sc = self.sigma_clip if sc is None or not isinstance(sc, SigmaClip): return None if not (isinstance(sc.cenfunc, str) and sc.cenfunc in ('median', 'mean', 'biweight')): return None if not (isinstance(sc.stdfunc, str) and sc.stdfunc in ('std', 'mad_std', 'biweight')): return None if sc.grow: # False or 0 is supported but spatial growing is not return None maxiters = sc.maxiters if maxiters is None or maxiters == np.inf: maxiters = -1 else: maxiters = int(maxiters) cenfunc_code = {'median': 0, 'mean': 1, 'biweight': 2}[sc.cenfunc] stdfunc_code = {'std': 0, 'mad_std': 1, 'biweight': 2}[sc.stdfunc] return (float(sc.sigma_lower), float(sc.sigma_upper), maxiters, cenfunc_code, stdfunc_code) def _apply_center_clip(self, gather, clip_spec): """ Sigma-clip the packed center buffer and return a `_BatchGather` whose center buffer reflects the per-source clipped data. """ sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc = clip_spec (cvalues, clx, cly, cstarts, ccounts, csorted) = batch_sigma_clip_center( gather.values, gather.local_x, gather.local_y, gather.starts, gather.counts, sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc) return gather._replace(values=cvalues, local_x=clx, local_y=cly, starts=cstarts, counts=ccounts, sorted_values=csorted) def _apply_sum_clip(self, gather, clip_spec): """ Sigma-clip the packed ``sum_method`` member buffers and return a `_BatchGather` whose aperture sum, variance, and area reflect the per-source clipped data. The packed member buffers are dropped (set to `None`) so their memory can be reclaimed. """ sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc = clip_spec has_error = 1 if self._error is not None else 0 (csum, cvar, carea) = batch_sigma_clip_sum( gather.sum_values, gather.sum_fracs, gather.sum_errsq, gather.starts, gather.sum_counts, sigma_lower, sigma_upper, maxiters, cenfunc, stdfunc, has_error) return gather._replace(sum_aper=csum, var_aper=cvar, sum_area=carea, sum_values=None, sum_fracs=None, sum_errsq=None, sum_counts=None) @cached_property def _sorted_values(self): """ The ``(sorted_values, starts, counts)`` tuple of the packed per-source ascending-sorted center pixel values. Each source's packed pixel values (see `_fast_gather`) are sorted once. This is used only when the fast batch path is available (see `_block_reduce`). The sorted buffer is cached and shared by the ``median``, ``mad_std``, and the biweight estimators (and by ``min`` and ``max`` when sigma clipping is applied), so the per-source sort is performed only once. When sigma clipping is applied, the sorted surviving values produced by the clipping kernel are reused directly (the clipping already sorts each source's values to compute the clip bounds). When ``n_threads`` > 1, the per-source sorts run concurrently (see `_threaded_reduction`). """ gather = self._fast_gather values, starts, counts = gather.values, gather.starts, gather.counts if gather.sorted_values is not None: return (gather.sorted_values, starts, counts) sorted_values = self._threaded_reduction( batch_sort_values, (values,), starts, counts) return (sorted_values, starts, counts) @cached_property def _order_stats(self): """ The per-source ``(min, max, median)`` arrays, or `None`. Reduced from the cached sorted buffer (see `_sorted_values`). `None` when the fast path is unavailable. """ if self._batch_inputs is None: return None return self._block_result('order_stats') @cached_property def _minmax(self): """ The per-source ``(min, max)`` arrays, or `None`. Reduced directly from the packed center buffer, so that ``min`` and ``max`` do not pay for the per-source sort. When sigma clipping is applied, the clipping kernel's sorted surviving values define the extremes, so the order statistics are used instead. `None` when the fast path is unavailable. """ if self._batch_inputs is None: return None if self._batch_inputs[-1] is not None: # sigma clipped vmin, vmax, _ = self._order_stats return vmin, vmax return self._block_result('minmax') @cached_property def _mean_var(self): """ The per-source ``(mean, var)`` arrays, or `None`. The population (``ddof=0``) variance is returned. Computed directly from the packed center buffer (no sort required). `None` when the fast path is unavailable. """ if self._batch_inputs is None: return None return self._block_result('mean_var') @cached_property def _mad(self): """ The per-source unscaled median absolute deviation, or `None`. Reduced from the cached sorted buffer (see `_sorted_values`). `None` when the fast path is unavailable. """ if self._batch_inputs is None: return None return self._block_result('mad') @cached_property def _biweight(self): """ The per-source ``(biweight_location, biweight_midvariance)`` arrays, or `None`. Reduced from the cached sorted buffer, reusing the median (`_order_stats`) and the unscaled MAD (`_mad`). `None` when the fast path is unavailable. """ if self._batch_inputs is None: return None return self._block_result('biweight') @cached_property def _gini(self): """ The per-source Gini coefficient, or `None`. Computed from the packed center buffer (the absolute values are sorted internally). `None` when the fast path is unavailable. """ if self._batch_inputs is None: return None return self._block_result('gini') def _finalize_value_stat(self, fast_result, stat_func, *, square_unit=False, apply_unit=True): """ Return a per-source value statistic, using the fast Cython reduction when available and otherwise the mask-based per-source code path. Parameters ---------- fast_result : `~numpy.ndarray` or `None` The per-source statistic from the fast reduction, or `None` to use the mask-based fallback. stat_func : callable The fallback callable applied to each source's 1D array of unmasked pixel values when the fast path is unavailable. square_unit : bool, optional Whether the statistic has squared data units (e.g., variance). apply_unit : bool, optional Whether to apply the data unit at all (`False` for dimensionless statistics such as the Gini coefficient). """ if fast_result is not None: result = fast_result.copy() else: result = np.array([stat_func(arr) for arr in self._data_values_center]) if apply_unit: unit = self._data_unit if unit is not None: if square_unit: unit = unit**2 result <<= unit return result @cached_property 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 @cached_property 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 @cached_property 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 @cached_property 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, *, count_clipped=True): """ 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. count_clipped : bool, optional Whether to count the number of sigma-clipped pixels per source. Only `_footprint_flag_inputs` for the "center" footprint uses this count, so it is skipped (left at 0) for the ``sum_method`` footprint to avoid the wasted computation. Returns ------- result : list of tuple One ``(data, variance, mask, weight, overlap, flag_counts, n_clipped)`` tuple for each source (aperture position). The first four are the cutout arrays (the variance is `None` if ``error`` is not input). They are followed by the overlap indicator, the per-source flag counts, and the number of sigma-clipped pixels (always 0 when ``count_clipped`` is `False`). The flag counts are indexed by the ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`. Only the columns read by the quality flags are filled, and those columns have the same meaning as in the batch drivers. """ # Use a local copy of the SigmaClip instance because SigmaClip # stores internal state on the instance during calls. This # method is reachable from two different cached properties (the # center- and sum-footprint cutouts), which do not share a lock, # so calling a shared instance from multiple threads could # silently corrupt the results. sigma_clip = copy(self.sigma_clip) data_cutouts = [] variance_cutouts = [] mask_cutouts = [] weight_cutouts = [] overlaps = [] flag_counts = [] n_clipped = [] positions = np.atleast_2d(self._pixel_aperture.positions) for idx, (data_cutout, apermask, slices) in enumerate( zip(self._data_cutouts, aperture_masks, self._overlap_slices, strict=True)): fc_row = np.zeros(N_FLAG_COLS, dtype=np.intp) n_clipped_ = 0 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 nonfinite ``data`` values combined # with the input ``mask`` array nonfinite_mask = ~np.isfinite(data_cutout) if self._mask is not None: user_mask = self._mask[slc_large] data_mask = nonfinite_mask | user_mask else: user_mask = None data_mask = nonfinite_mask # The variance is calculated in float64, as it is in # the compiled code error_cutout = (None if self._error is None else self._error[slc_large].astype(float)) # Apply segmentation-based masking and/or symmetric # neighbor correction exclude = None affected = None if (self._segmentation is not None and self.mask_method != 'none'): segm_cutout = self._segmentation[slc_large] cutout_xycen = (positions[idx, 0] - slc_large[1].start, positions[idx, 1] - slc_large[0].start) (data_cutout, error_cutout, exclude, affected) = make_segmentation_exclusion( self.mask_method, segm_cutout, self._seg_labels[idx], data=data_cutout, error=error_cutout, base_mask=data_mask, cutout_xycen=cutout_xycen) pre_seg_mask = data_mask data_mask = data_mask | exclude overlap = True aperweight_cutout = apermask.data[slc_small] weight_cutout = aperweight_cutout * ~data_mask # Per-source pixel counts for the quality flags, # matching the batch-driver semantics (computed before # any sigma clipping) weighted = aperweight_cutout > 0 fc_row[FLAG_COL_N_PIXELS] = np.count_nonzero(weighted) fc_row[FLAG_COL_BBOX_CLIPPED] = ( aperweight_cutout.shape != apermask.data.shape) if user_mask is not None: fc_row[FLAG_COL_MASKED] = np.count_nonzero( weighted & user_mask) fc_row[FLAG_COL_NONFINITE_DATA] = np.count_nonzero( weighted & nonfinite_mask & ~user_mask) else: fc_row[FLAG_COL_NONFINITE_DATA] = np.count_nonzero( weighted & nonfinite_mask) if affected is not None: pre_valid = weighted & ~pre_seg_mask fc_row[FLAG_COL_SEG] = np.count_nonzero( affected & pre_valid) if self.mask_method == 'correct': # In 'correct' mode, the excluded pixels are # exactly the uncorrectable neighbor pixels fc_row[FLAG_COL_UNCORRECTED] = np.count_nonzero( exclude & pre_valid) fc_row[FLAG_COL_VALID] = np.count_nonzero( weighted & ~data_mask) if error_cutout is not None: fc_row[FLAG_COL_NONFINITE_ERROR] = np.any( ~np.isfinite(error_cutout) & weighted & ~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 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 = sigma_clip(data_cutout_ma) # Define a mask of only the sigma-clipped pixels sigclip_mask = data_sigclip.mask & ~mask_cutout if count_clipped: n_clipped_ = np.count_nonzero(sigclip_mask) 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 = error_cutout**2 variance_cutout = (variance * aperweight_cutout**2 * ~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) flag_counts.append(fc_row) n_clipped.append(n_clipped_) # 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, flag_counts, n_clipped, strict=True)) @cached_property 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) @cached_property def _aperture_cutouts(self): """ Aperture-weighted cutouts for the data, variance, total mask, and aperture weights using the input ``sum_method`` aperture mask method. """ # The sigma-clipped pixel count is not tracked for this # footprint. Only the "center" footprint's flags use it (see # `_footprint_flag_inputs`), so counting it here would be wasted # computation. return self._make_aperture_cutouts(self._aperture_masks, count_clipped=False) @cached_property def _mask_cutout_center(self): """ Boolean mask cutouts representing the total mask. The total mask is a combination of the input ``mask``, nonfinite ``data`` values, the cutout aperture mask using the "center" method, and the sigma-clip mask. """ fast = self._fast_cutouts_center if fast is not None: return fast[2] return list(zip(*self._aperture_cutouts_center, strict=True))[2] @cached_property def _mask_cutout(self): """ Boolean mask cutouts representing the total mask. The total mask is a combination of the input ``mask``, nonfinite ``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] @cached_property def _fast_cutouts_center(self): """ The center-method cutout arrays reconstructed from the packed gather buffers, or `None`. Returns a ``(data, variance, mask, weight)`` tuple of per-source cutout lists identical to the mask-based `_aperture_cutouts_center` values. The packed buffer holds exactly the surviving pixels (unmasked, finite, inside the center-method aperture, segmentation-kept, and sigma-clip surviving) together with their cutout coordinates, so the total mask is `True` for every pixel absent from the buffer, and the weight cutout is zero there. The data and variance cutouts are also zero there, except that they are NaN at nonfinite ``data`` pixels (without sigma clipping) and at nonfinite ``error`` pixels, matching the mask-based arrays. Sources whose bounding box does not overlap the data get the same single-NaN sentinel arrays as the mask-based path. `None` is returned (and the mask-based path is used) when the fast gather is unavailable or when segmentation neighbor correction is applied, since ``mask_method='correct'`` replaces neighbor-pixel data *and error* values with their mirrored values, and the mirrored error values are not recoverable from the packed buffers. """ gather = self._fast_gather if gather is None or (self._segmentation is not None and self.mask_method == 'correct'): return None # The kernel's per-source cutout origin is the bounding box # clipped to the data, using the same integer bounds as # ``PixelAperture._bbox_bounds`` (upper bounds exclusive). bounds = self._pixel_aperture._bbox_bounds ny, nx = self._data.shape x0 = np.maximum(bounds[:, 0], 0) x1 = np.minimum(bounds[:, 1], nx) y0 = np.maximum(bounds[:, 2], 0) y1 = np.minimum(bounds[:, 3], ny) overlaps = ((bounds[:, 0] < nx) & (bounds[:, 1] > 0) & (bounds[:, 2] < ny) & (bounds[:, 3] > 0)) error = self._error starts, counts = gather.starts, gather.counts values = gather.values local_x, local_y = gather.local_x, gather.local_y data_cutouts = [] variance_cutouts = [] mask_cutouts = [] weight_cutouts = [] for idx, overlap in enumerate(overlaps): if not overlap: # Match the mask-based no-overlap sentinels data_cutouts.append(np.array([np.nan])) variance_cutouts.append(np.array([np.nan])) mask_cutouts.append(np.array([False])) weight_cutouts.append(np.array([np.nan])) continue shape = (y1[idx] - y0[idx], x1[idx] - x0[idx]) slc = (slice(y0[idx], y1[idx]), slice(x0[idx], x1[idx])) i0 = starts[idx] i1 = i0 + counts[idx] lx = local_x[i0:i1] ly = local_y[i0:i1] data_cutout = np.zeros(shape) data_cutout[ly, lx] = values[i0:i1] if self.sigma_clip is None: # The mask-based path multiplies the data by the # boolean masks, so nonfinite pixels are NaN in the # cutout data instead of zero. (With sigma clipping # it instead fills every masked pixel with zero.) data_cutout[~np.isfinite(self._data[slc])] = np.nan mask_cutout = np.ones(shape, dtype=bool) mask_cutout[ly, lx] = False weight_cutout = np.zeros(shape) weight_cutout[ly, lx] = 1.0 if error is None: variance_cutout = None else: # Multiplying the full variance cutout by the weights # replicates the mask-based arithmetic exactly, # including NaN for masked nonfinite error values. The # center-method weights are 0 or 1, so squaring them is # not needed. variance_cutout = (error[slc].astype(float) ** 2 * weight_cutout) data_cutouts.append(data_cutout) variance_cutouts.append(variance_cutout) mask_cutouts.append(mask_cutout) weight_cutouts.append(weight_cutout) return (data_cutouts, variance_cutouts, mask_cutouts, weight_cutouts) def _make_masked_array_center(self, array): """ Return a list of cutout masked arrays using the ``_mask_cutout_center`` 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_cutout`` mask. Units are not applied. """ return [np.ma.masked_array(arr, mask=mask) for arr, mask in zip(array, self._mask_cutout, strict=True)] @cached_property 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 ``local_bkg`` value is subtracted from the cutout data values. If ``mask_method='correct'``, the corrected neighbor pixels hold their mirrored values. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, nonfinite ``data`` values (NaN and inf), pixels excluded by the segmentation masking (``mask_method``), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. For more than one aperture position, a list with one cutout for each position is returned. If the aperture does not overlap the data, the cutout is a one-element array containing NaN. """ fast = self._fast_cutouts_center if fast is not None: return self._make_masked_array_center(fast[0]) return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[0]) @cached_property 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 ``local_bkg`` value is subtracted from the data values before the aperture weights are applied. If ``mask_method='correct'``, the corrected neighbor pixels hold their mirrored values. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, nonfinite ``data`` values (NaN and inf), pixels excluded by the segmentation masking (``mask_method``), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. For more than one aperture position, a list with one cutout for each position is returned. If the aperture does not overlap the data, the cutout is a one-element array containing NaN. """ return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[0]) @cached_property def _variance_cutout_center(self): """ A 2D variance cutout weighted by the squared aperture mask weights, 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``, nonfinite ``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 fast = self._fast_cutouts_center if fast is not None: return self._make_masked_array_center(fast[1]) return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[1]) @cached_property def _variance_cutout(self): """ A 2D variance cutout weighted by the squared aperture mask weights, 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``, nonfinite ``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]) @cached_property 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 values are the pixel errors multiplied by the aperture mask weights, so the quadrature sum of the unmasked cutout values equals `sum_err`. If ``mask_method='correct'``, the corrected neighbor pixels hold their mirrored error values. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, nonfinite ``data`` values (NaN and inf), pixels excluded by the segmentation masking (``mask_method``), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. For more than one aperture position, a list with one cutout for each position is returned. If the aperture does not overlap the data, the cutout is a one-element array containing NaN. `None` if ``error`` is not input (an array of `None` values for more than one aperture position). """ if self._error is None: return self._null_object return [np.sqrt(var) for var in self._variance_cutout] @cached_property 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``, nonfinite ``data`` values (NaN and inf), and sigma-clipped pixels. """ fast = self._fast_cutouts_center if fast is not None: return self._make_masked_array_center(fast[3]) return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[3]) @cached_property 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``, nonfinite ``data`` values (NaN and inf), and sigma-clipped pixels. """ return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[3]) @cached_property 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._array('data_cutout')) 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 @cached_property 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]) def _footprint_flag_inputs(self, footprint): """ The per-source flag inputs for the given aperture footprint. Parameters ---------- footprint : {'center', 'sum'} The aperture footprint: the "center" mask method used by the value statistics, or the ``sum_method`` mask method used by the sum properties. Returns ------- flag_counts : `~numpy.ndarray` The per-source flag counts (see the ``FLAG_COL_*`` constants in `photutils.aperture._batch_photometry`). overlap : `~numpy.ndarray` (bool) Whether the aperture bounding box overlaps the data. n_kept : `~numpy.ndarray` or `None` For the center footprint, the per-source number of valid pixels remaining after sigma clipping, or `None` for the sum footprint. """ if footprint == 'center': meta = self._center_gather_meta if meta is not None: counts, overlap, flag_counts = meta return flag_counts, np.asarray(overlap), np.asarray(counts) cutouts = self._aperture_cutouts_center else: gather = self._fast_sum if gather is not None: return gather.flag_counts, np.asarray(gather.overlap), None cutouts = self._aperture_cutouts flag_counts = np.array([cut[5] for cut in cutouts]) overlap = np.array([cut[4] for cut in cutouts]) n_kept = None if footprint == 'center': n_clipped = np.array([cut[6] for cut in cutouts]) n_kept = flag_counts[:, FLAG_COL_VALID] - n_clipped return flag_counts, overlap, n_kept def _footprint_flags(self, footprint): """ The per-source flag bits for the given aperture footprint. The bounding-box-clipped candidate sources are resolved to the precise outside-weight test (see `PixelAperture._resolve_outside_weights`). """ flag_counts, overlap, _ = self._footprint_flag_inputs(footprint) if footprint == 'center': method, subpixels = 'center', 1 else: method, subpixels = self.sum_method, self.subpixels candidates = flag_counts[:, FLAG_COL_BBOX_CLIPPED].astype(bool) w_out = self._pixel_aperture._resolve_outside_weights( self._data.shape, method=method, subpixels=subpixels, candidates=candidates) return _counts_to_flag_bits(flag_counts, overlap, w_out) @cached_property def _base_flags(self): """ The count-based value-statistics flag bits (1D int array). These are the "center"-method footprint bits plus the sigma-clip and ``ddof`` bits. The `flags` property combines them with the ``sum_method`` footprint bits and the ``undefined_shape``, ``singular_covariance``, and ``centroid_outside_bbox`` bits. """ # The gather kernel and the center-method cutouts do not # evaluate error values, so the non-finite-error bit is stripped # here. `flags` picks it up from the sum footprint, the only # footprint where error values are evaluated. flags = (self._footprint_flags('center') & ~APERTURE_FLAGS.NON_FINITE_ERROR) flag_counts, _, n_kept = self._footprint_flag_inputs('center') n_valid = flag_counts[:, FLAG_COL_VALID] if self.sigma_clip is not None: n_clipped = n_valid - n_kept flags[n_clipped > 0] |= APERTURE_FLAGS.SIGMA_CLIPPED flags[(n_valid > 0) & (n_kept == 0)] |= APERTURE_FLAGS.ALL_CLIPPED # The standard errors are undefined for a single valid pixel # and the variance needs more than ddof valid pixels w_in = flag_counts[:, FLAG_COL_N_PIXELS] too_few = n_kept == 1 if self.ddof > 0: too_few |= n_kept <= self.ddof flags[(w_in > 0) & too_few] |= APERTURE_FLAGS.TOO_FEW_PIXELS return flags @cached_property def _undefined_shape_mask(self): """ Boolean mask (1D) marking sources whose net flux is not positive or whose covariance matrix is not positive semidefinite. The net flux is the zeroth image moment of the unmasked "center"-method pixels. When it is zero or negative, the centroid and the covariance-derived shape properties are undefined or unreliable. A source with a positive net flux can still have second-order moments that are not positive semidefinite (e.g., from negative pixel values), which do not describe a shape. Its covariance-derived shape properties are NaN. A fully masked source has a zero net flux, so it is also flagged, which matches the equivalent `~photutils.segmentation.SourceCatalog` flag. Sources with no overlap and fully sigma-clipped sources are not flagged here. They are already reported by the overlap and clipping bits. """ m00 = self._array('moments')[:, 0, 0] # NaN where a source has no valid pixels n_pixels = self._center_n_pixels non_positive = (np.isfinite(m00) & (m00 <= 0) & np.isfinite(n_pixels)) # The same definition as the ``'all_masked'`` flag on the # "center"-method footprint flag_counts, _, _ = self._footprint_flag_inputs('center') all_masked = ((flag_counts[:, FLAG_COL_N_PIXELS] > 0) & (flag_counts[:, FLAG_COL_VALID] == 0)) invalid = is_invalid_covariance( self._raw_covariance, determinant=self._raw_covariance_det) return non_positive | all_masked | invalid @cached_property def _singular_covariance_mask(self): """ Boolean mask (1D) marking sources with a singular or nearly singular covariance matrix. A source is flagged when the minor-axis variance (the smaller eigenvalue of its normalized second-moment covariance matrix) falls below ``PIXEL_VARIANCE`` (``1/12``, defined in ``photutils.utils._moments``). This flags both unresolved, nearly point-like sources, where the covariance determinant drops below ``PIXEL_VARIANCE**2``, and rank-1 degenerate sources, where one axis is unresolved while the other is extended (which the determinant alone would miss). Sources with undefined moments (no overlap or fully masked) have a NaN determinant and are not flagged here. They are already reported by the overlap and masking bits. These sources get the ``'singular_covariance'`` flag, and they are exactly the ones whose covariance is regularized. A source whose covariance is not positive semidefinite is not flagged here. Its covariance is NaN and it gets the ``'undefined_shape'`` flag instead. """ return is_singular_covariance(self._raw_covariance, determinant=self._raw_covariance_det) @cached_property def _centroid_outside_bbox_mask(self): """ Boolean mask (1D) marking sources whose centroid lies outside the aperture bounding box. The image moments include negative pixel values, so the centroid is not bounded by the aperture when the net flux is small compared to the noise. Sources with a NaN centroid are not flagged here. They are already reported by the ``'undefined_shape'`` and overlap bits. An infinite centroid (a zero net flux with a nonzero first moment) is flagged. """ centroid = self._array('centroid') # The inclusive integer pixel bounds span half a pixel beyond # the first and last pixel centers. xmin, xmax, ymin, ymax = np.transpose(self._bbox_bounds) # A NaN centroid compares false with np.errstate(invalid='ignore'): return ((centroid[:, 0] < xmin - 0.5) | (centroid[:, 0] > xmax + 0.5) | (centroid[:, 1] < ymin - 0.5) | (centroid[:, 1] > ymax + 0.5)) @cached_property @_update_method_subpixels_docstring def flags(self): # numpydoc ignore: RT01 """ The bitwise quality flags. The footprint-based flags (e.g., ``'masked_pixels'``, ``'non_finite_data'``, and the overlap flags) are evaluated on the union of the "center"-method footprint used by the value statistics and the ``sum_method`` footprint used by the sum properties. The ``'no_overlap'`` flag is set only if neither footprint overlaps the data. The ``'non_finite_error'`` flag is evaluated on the ``sum_method`` footprint. The ``'sigma_clipped'``, ``'all_clipped'``, and ``'too_few_pixels'`` flags are evaluated on the value-statistics footprint. The ``'too_few_pixels'`` flag is set when the aperture has a single valid pixel (after any sigma clipping), for which ``mean_err`` and ``median_err`` are undefined, or when ``ddof`` is greater than zero and the number of valid pixels is not larger than ``ddof``. The ``'undefined_shape'``, ``'singular_covariance'``, and ``'centroid_outside_bbox'`` flags are always evaluated. Accessing ``flags`` computes the moment and covariance properties if they have not already been computed (the results are cached and shared with the corresponding shape properties). See `~photutils.aperture.decode_aperture_flags` for decoding flag values. The flags are: """ # The | allocates a new array, so the in-place |= below never # mutates the cached `_base_flags` base_flags = self._base_flags sum_flags = self._footprint_flags('sum') flags = base_flags | sum_flags # The aperture overlaps the data if either footprint does no_overlap = APERTURE_FLAGS.NO_OVERLAP flags &= ~no_overlap | (base_flags & sum_flags) flags[self._undefined_shape_mask] |= ( APERTURE_FLAGS.UNDEFINED_SHAPE) flags[self._singular_covariance_mask] |= ( APERTURE_FLAGS.SINGULAR_COVARIANCE) flags[self._centroid_outside_bbox_mask] |= ( APERTURE_FLAGS.CENTROID_OUTSIDE_BBOX) return flags def decode_flags(self, *, return_bit_values=False): """ Decode the source quality flags into individual components. This is a convenience method that calls `~photutils.aperture.decode_aperture_flags` with the `flags` property. Parameters ---------- return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag names (strings). Returns ------- decoded : dict A dictionary mapping each aperture position `id` to the list of its active flag names (or bit values). The entries follow the position order. See Also -------- photutils.aperture.decode_aperture_flags Examples -------- >>> import numpy as np >>> from photutils.aperture import ApertureStats, CircularAperture >>> data = np.ones((25, 25)) >>> mask = np.zeros(data.shape, dtype=bool) >>> mask[12, 12] = True >>> aper = CircularAperture([(12.0, 12.0), (0.0, 12.0)], r=3.0) >>> aperstats = ApertureStats(data, aper, mask=mask) >>> for source_id, names in aperstats.decode_flags().items(): ... print(source_id, names) 1 ['masked_pixels'] 2 ['partial_overlap'] """ decoded = decode_aperture_flags(self._array('flags'), return_bit_values=return_bit_values) return {int(id_): flags for id_, flags in zip(self._array('id'), decoded, strict=True)} def _get_values(self, array): """ Get a list of 1D arrays (one for each source) 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] @cached_property def _data_values_center(self): """ A list of 1D arrays (one for each source) 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) @cached_property def moments(self): """ Spatial moments up to 3rd order of the source. The result is a ``(4, 4)`` array for each aperture position, where element ``[i, j]`` is the sum of ``y**i * x**j`` times the pixel values. The coordinates are relative to the origin of the cutout (see `cutout_centroid`). The moments are NaN if the aperture does not overlap the data. """ meta = self._center_gather_meta if meta is not None: overlap = meta[1] mom = self._block_result('moments').copy() # No-overlap sources have NaN moments (the mask-based path # uses an all-NaN cutout). All-masked overlapping sources # have zero moments (an all-zero cutout). mom[~overlap] = np.nan return mom return np.array([image_moments(arr, order=3) for arr in self._moment_data_cutout]) @cached_property def moments_central(self): """ Central moments (translation invariant) of the source up to 3rd order. The result is a ``(4, 4)`` array for each aperture position, where element ``[i, j]`` is the sum of ``(y - yc)**i * (x - xc)**j`` times the pixel values and ``(xc, yc)`` is the centroid (see `cutout_centroid`). The moments are NaN if the aperture does not overlap the data. If no unmasked pixels remain, the moments are NaN except for the ``[0, 0]`` element, which is zero. """ meta = self._center_gather_meta if meta is not None: mom = self._block_result('moments_central').copy() # Empty sources (no overlap or fully masked) have a NaN # centroid, so their central moments are NaN (matching the # mask-based path). The zeroth central moment does not # depend on the centroid, so it equals the zeroth raw # moment (zero for a fully masked source). empty = meta[0] == 0 mom[empty] = np.nan mom[empty, 0, 0] = self._array('moments')[empty, 0, 0] return mom cutout_centroid = self._array('cutout_centroid') 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)]) @cached_property 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. """ return centroid_from_moments(self._array('moments')) @cached_property 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. """ # The cutout is the bounding box clipped to the data, so its # origin is never negative. origin = np.transpose((np.maximum(self.bbox_xmin, 0), np.maximum(self.bbox_ymin, 0))) return self.cutout_centroid + origin @cached_property 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 np.transpose(self._array('centroid'))[0] @cached_property 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 np.transpose(self._array('centroid'))[1] @cached_property 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 (an array of `None` values for more than one aperture position). """ if self._wcs is None: return self._null_object return self._wcs.pixel_to_world(self.x_centroid, self.y_centroid) @cached_property 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 (an array of `None` values for more than one aperture position). """ if self._wcs is None: return self._null_object return self.sky_centroid.icrs @cached_property 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] @cached_property 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, so it may be slightly larger than the region of nonzero weights in the aperture mask calculated using the "center" method. """ return self._bbox @cached_property def _bbox_bounds(self): """ The bounding box x and y minimum and maximum (inclusive) bounds, as an ``(n_positions, 4)`` array. When the aperture uses the default ``bbox`` implementation, the bounds are taken from the aperture's vectorized integer bounds, so no per-position `~photutils.aperture.BoundingBox` objects are created. An aperture subclass that overrides ``bbox`` falls back to reading the per-position objects. """ aper = self._pixel_aperture if type(aper).bbox is PixelAperture.bbox: # Convert the exclusive upper bounds to inclusive return aper._bbox_bounds - [0, 1, 0, 1] bbox = self._array('bbox') # The reshape preserves the (n_positions, 4) shape when there # are zero positions return np.array([(bbox_.ixmin, bbox_.ixmax - 1, bbox_.iymin, bbox_.iymax - 1) for bbox_ in bbox], dtype=int).reshape(-1, 4) @cached_property def bbox_xmin(self): """ The minimum ``x``-pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[0] @cached_property 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] @cached_property def bbox_ymin(self): """ The minimum ``y``-pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[2] @cached_property 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] @cached_property def _center_n_pixels(self): """ The number of unmasked pixels within each aperture using the "center" mask method. The result is a `~numpy.ndarray` of per-source pixel counts. Sources with no unmasked pixels are set to NaN. """ meta = self._center_gather_meta if meta is not None: counts = meta[0] n_pixels = counts.astype(float) n_pixels[counts == 0] = np.nan return n_pixels n_pixels = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout_center]) n_pixels[self._all_masked] = np.nan return n_pixels @cached_property def _sem(self): """ The standard error of the mean for each aperture. The result is a `~numpy.ndarray` (without data units). It is computed as ``s / sqrt(N)``, where ``s`` is the sample (``N - 1``) standard deviation and ``N`` is the number of unmasked pixels within the aperture. Sources with fewer than two unmasked pixels are set to NaN. """ stats = self._mean_var if stats is not None: var = stats[1] else: var = np.array([np.var(values) for values in self._data_values_center]) n_pixels = self._center_n_pixels sem = np.full(self.n_positions, np.nan) mask = n_pixels >= 2 # var is the population (ddof=0) variance, so var / (N - 1) # equals the squared standard error of the mean. sem[mask] = np.sqrt(var[mask] / (n_pixels[mask] - 1.0)) return sem @cached_property def center_aper_area(self): """ The total area of the unmasked pixels within the aperture using the "center" aperture mask method. """ return self._center_n_pixels * (u.pix**2) @cached_property def sum_aper_area(self): """ The total area of the unmasked pixels within the aperture using the input ``sum_method`` aperture mask method. """ gather = self._fast_sum if gather is not None: area, overlap = gather.sum_area.copy(), gather.overlap area[overlap & (area == 0)] = np.nan return area << (u.pix**2) areas = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout]) # NaN only when no sum-method pixel survives. The center-method # ``_all_masked`` flag must not be used here. When ``sum_method`` # is not "center", unmasked boundary pixels can carry a nonzero # fractional area even if every center-method pixel is masked # (and their values then also contribute to ``sum``). areas[areas == 0] = np.nan return areas << (u.pix**2) @cached_property def sum(self): r""" The sum of the unmasked ``data`` values within the aperture, weighted by the aperture overlap fractions. .. math:: F = \sum_{i \in A} w_i I_i where :math:`F` is ``sum``, :math:`w_i` are the aperture overlap fractions (1 for pixels entirely within the aperture), :math:`I_i` is the background-subtracted ``data``, and :math:`A` are the unmasked pixels in the aperture. Nonfinite pixel values (NaN and inf) are excluded (automatically masked). If ``sigma_clip`` is input, the sigma-clipped pixels are also excluded. """ gather = self._fast_sum if gather is not None: result = gather.sum_aper.copy() area = gather.sum_area overlap = gather.overlap # No sum-method survivors -> NaN (matches the mask-based # path, which returns NaN for an all-masked aperture). result[overlap & (area == 0)] = np.nan if self._data_unit is not None: result <<= self._data_unit return result if self.sum_method == 'center': return self._finalize_value_stat(None, 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 @cached_property def sum_err(self): r""" The uncertainty of `sum`, propagated from the input ``error`` array. Because `sum` is the sum of the pixel values weighted by their aperture overlap fractions, ``sum_err`` is the quadrature sum of the total errors over the unmasked pixels within the aperture, with each pixel variance weighted by the squared overlap fraction: .. math:: \Delta F = \sqrt{\sum_{i \in A} w_i^2 \sigma_{\mathrm{tot}, i}^2} where :math:`\Delta F` is the `sum_err`, :math:`w_i` are the aperture overlap fractions (1 for pixels entirely within the aperture), :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 nonfinite pixel values (NaN and inf) that are automatically masked, are also masked in the error array. NaN if ``error`` is not input. """ if self._error is None: err = self._null_value else: gather = self._fast_sum if gather is not None: variance = gather.var_aper.copy() area = gather.sum_area overlap = gather.overlap variance[overlap & (area == 0)] = np.nan err = np.sqrt(variance) 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 @cached_property def min(self): """ The minimum of the unmasked pixel values within the aperture. """ fast = None if self._minmax is None else self._minmax[0] return self._finalize_value_stat(fast, np.min) @cached_property def max(self): """ The maximum of the unmasked pixel values within the aperture. """ fast = None if self._minmax is None else self._minmax[1] return self._finalize_value_stat(fast, np.max) @cached_property def mean(self): """ The mean of the unmasked pixel values within the aperture. """ fast = None if self._mean_var is None else self._mean_var[0] return self._finalize_value_stat(fast, np.mean) @cached_property def mean_err(self): r""" The standard error of the `mean`. ``mean_err`` is the standard deviation of the sampling distribution of the mean: .. math:: \sigma_{\bar{x}} = \frac{s}{\sqrt{N}} where :math:`s` is the sample standard deviation (computed with ``N - 1`` in the denominator) and :math:`N` is the number of unmasked pixels within the aperture (`center_aper_area`). Apertures with fewer than two unmasked pixels have an undefined standard error and are set to NaN. """ result = self._sem.copy() if self._data_unit is not None: result <<= self._data_unit return result @cached_property def median(self): """ The median of the unmasked pixel values within the aperture. """ fast = None if self._order_stats is None else self._order_stats[2] return self._finalize_value_stat(fast, np.median) @cached_property def median_err(self): r""" The standard error of the `median`. ``median_err`` is the large-sample approximation of the standard error of the median: .. math:: \sigma_{\mathrm{med}} \approx \sqrt{\frac{\pi}{2}} \ \frac{s}{\sqrt{N}} where :math:`s` is the sample standard deviation (computed with ``N - 1`` in the denominator) and :math:`N` is the number of unmasked pixels within the aperture (`center_aper_area`). This approximation assumes that the pixel values are approximately normally distributed. Apertures with fewer than two unmasked pixels have an undefined standard error and are set to NaN. """ result = np.sqrt(np.pi / 2.0) * self._sem if self._data_unit is not None: result <<= self._data_unit return result @cached_property 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 @cached_property def _variance(self): """ The variance of the unmasked pixel values within each aperture as a plain `~numpy.ndarray` (without data units). The population (``ddof=0``) variance is computed using the fast Cython reduction when available and otherwise the mask-based per-source code path. When ``self.ddof`` is nonzero, the result is rescaled by ``N / (N - ddof)``, where ``N`` is the number of unmasked pixels within the aperture. Apertures with ``N <= ddof`` unmasked pixels are set to NaN. """ fast = None if self._mean_var is None else self._mean_var[1] var = self._finalize_value_stat(fast, np.var, apply_unit=False) if self.ddof == 0: return var n_pixels = self._center_n_pixels result = np.full(self.n_positions, np.nan) mask = n_pixels > self.ddof result[mask] = (var[mask] * n_pixels[mask] / (n_pixels[mask] - self.ddof)) return result @cached_property def std(self): """ The standard deviation of the unmasked pixel values within the aperture. The divisor used in the calculation is ``N - ddof``, where ``N`` is the number of unmasked pixels within the aperture and ``ddof`` is the value of the ``ddof`` keyword (default 0). """ result = np.sqrt(self._variance) if self._data_unit is not None: result <<= self._data_unit return result @cached_property def mad_std(self): r""" The standard deviation of the unmasked pixel values within the aperture, 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`. """ fast = None if self._mad is None else self._mad * _MAD_STD_SCALE return self._finalize_value_stat(fast, mad_std) @cached_property def var(self): """ The variance of the unmasked pixel values within the aperture. The divisor used in the calculation is ``N - ddof``, where ``N`` is the number of unmasked pixels within the aperture and ``ddof`` is the value of the ``ddof`` keyword (default 0). """ result = self._variance.copy() if self._data_unit is not None: result <<= self._data_unit**2 return result @cached_property def biweight_location(self): """ The biweight location of the unmasked pixel values within the aperture. The tuning constant is fixed at ``c=6``, the default value used by `astropy.stats.biweight_location`. """ fast = None if self._biweight is None else self._biweight[0] return self._finalize_value_stat(fast, biweight_location) @cached_property def biweight_midvariance(self): """ The biweight midvariance of the unmasked pixel values within the aperture. The tuning constant is fixed at ``c=9``, the default value used by `astropy.stats.biweight_midvariance`. """ fast = None if self._biweight is None else self._biweight[1] return self._finalize_value_stat(fast, biweight_midvariance, square_unit=True) @property @deprecated('3.1', until='4.0') def inertia_tensor(self): """ The inertia tensor of the source for the rotation around its center of mass. .. deprecated:: 3.1 The inertia tensor carries no information beyond the `covariance` matrix. Use the `covariance` matrix and the shape properties that are derived from it instead. The rows and columns of the ``(2, 2)`` tensor are ordered ``(y, x)``, the reverse of the `covariance` matrix. The diagonal elements are the moments of inertia about the y- and x-axes and the off-diagonal elements are the negative of the product of inertia. The tensor is not normalized by the total flux. """ moments = self._array('moments_central') return inertia_tensor_from_moments(moments) * u.pix**2 @cached_property def _raw_covariance_det(self): """ The determinant of the raw ``(N, 2, 2)`` covariance matrix. It is computed once and shared by `_covariance`, `_singular_covariance_mask`, and `_undefined_shape_mask`. """ return covariance_determinant(self._raw_covariance) @cached_property def _raw_covariance(self): """ The raw ``(N, 2, 2)`` covariance matrix of the 2D Gaussian function that has the same normalized second-order moments as the source, before any regularization. This unregularized matrix is shared by `_covariance` (which regularizes a copy) and by `_singular_covariance_mask` and `_undefined_shape_mask` (which test it for singularity and validity). Callers that modify the matrix in place must operate on a copy so the cached value is not corrupted. """ return covariance_from_moments(self._array('moments_central')) @cached_property 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. """ return regularize_covariance( self._raw_covariance, determinant=self._raw_covariance_det) @cached_property def covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source. The variance along each principal axis is at least ``1/12`` pixel**2 (to within floating-point rounding), the variance of a uniform distribution across a single pixel. For a source that is unresolved along an axis (e.g., a point-like or a very thin source), the variance along that axis is raised to ``1/12`` while the orientation and the variance along a resolved axis are unchanged. A source that is unresolved along both axes is isotropic, with an orientation of zero. The covariance is NaN if the second-order moments are not positive semidefinite. """ return self._covariance * (u.pix**2) @cached_property def covariance_eigvals(self): """ The two eigenvalues of the `covariance` matrix in decreasing order. """ return eigvals_from_covariance(self._covariance) * u.pix**2 @cached_property 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._array('covariance_eigvals') return np.sqrt(eigvals[:, 0]) @cached_property 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._array('covariance_eigvals') return np.sqrt(eigvals[:, 1]) @cached_property 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)) @cached_property 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 counterclockwise direction and is in the range (-90, 90] degrees. """ return orientation_from_covariance(self._covariance) * u.deg @cached_property def sky_orientation(self): """ The position angle on the sky of the major axis of the 2D Gaussian function that has the same second-order moments as the source. The pixel `covariance` matrix is transported to the local tangent plane with the WCS Jacobian evaluated at each source `centroid`, which accounts for the WCS rotation, parity, and any local distortion. The position angle is measured from North toward East (i.e., counterclockwise on the sky) in the celestial frame of the input ``wcs`` and is in the range (-90, 90] degrees. This is the same convention as the ``theta`` parameter of the sky-based apertures (e.g., `~photutils.aperture.SkyEllipticalAperture`). `None` if ``wcs`` is not input (an array of `None` values for more than one aperture position). """ if self._wcs is None: return self._null_object sky_cov = pixel_to_sky_covariance(self._wcs, self._covariance, self._array('centroid')) return sky_orientation_from_covariance(sky_cov) * u.deg @cached_property 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)) @cached_property 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 @cached_property def ellipticity(self): r""" 1.0 minus the ratio of the lengths of the semiminor and semimajor axes (or 1.0 divided by the `elongation`, subtracted from 1.0). .. math:: \mathrm{ellipticity} = \frac{a - b}{a} = 1 - \frac{b}{a} = 1 - \frac{1}{\mathrm{elongation}} 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) @cached_property 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 @cached_property 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 @cached_property def covariance_xy(self): r""" The ``(0, 1)`` and ``(1, 0)`` elements of the `covariance` matrix, representing :math:`\sigma_{xy}` (the covariance of ``x`` and ``y``), in units of pixel**2. """ return self._covariance[:, 0, 1] * u.pix**2 @cached_property 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). 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) @cached_property 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). 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) @cached_property 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). 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))) @cached_property 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_{i=1}^{n} (2i - n - 1) \left | x_i \right | where the :math:`n` pixel values :math:`x_i` are sorted in order of increasing absolute value and :math:`\overline{|x|}` is the mean of their absolute values. If all pixel values are 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. If no unmasked pixels remain, the Gini coefficient is NaN. """ return self._finalize_value_stat(self._gini, gini_func, apply_unit=False) def __getattr__(name): # DEFAULT_COLUMNS is a deprecated public name for the 3.0 default # to_table() columns, which did not include the flags column. if name == 'DEFAULT_COLUMNS': msg = ('DEFAULT_COLUMNS is deprecated and will be removed in ' 'version 4.0. Use the ApertureStats.default_columns attribute ' 'instead.') warnings.warn(msg, PhotutilsDeprecationWarning, stacklevel=2) return ['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'] msg = f'module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) astropy-photutils-24be529/photutils/aperture/tests/000077500000000000000000000000001526452531300226345ustar00rootroot00000000000000astropy-photutils-24be529/photutils/aperture/tests/__init__.py000066400000000000000000000000001526452531300247330ustar00rootroot00000000000000astropy-photutils-24be529/photutils/aperture/tests/conftest.py000066400000000000000000000042711526452531300250370ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Shared pytest fixtures and helpers for the aperture tests. """ import numpy as np import pytest from astropy.wcs import WCS from photutils.aperture import CircularAperture from photutils.datasets import make_4gaussians_image # The shape of the small uniform image used by the quality-flag tests UNIT_SHAPE = (25, 25) class NoBatchCircularAperture(CircularAperture): """ A `CircularAperture` subclass that does not opt in to the batch Cython driver (it is not decorated with ``_enable_batch_photometry``), forcing the mask-based code path. """ def make_scene(): """ Build a deterministic scene with a target source (label 1) and a bright neighbor source (label 2), on a nonzero background. Returns ------- data, segm : 2D `~numpy.ndarray` The image and the matching segmentation image. """ data = np.ones((50, 50)) segm = np.zeros((50, 50), dtype=int) # Target source (label 1) data[18:25, 18:25] = 10.0 segm[18:25, 18:25] = 1 # Bright neighbor source (label 2) data[20:25, 26:32] = 100.0 segm[20:25, 26:32] = 2 return data, segm @pytest.fixture(name='data') def fixture_data(): """ A 2D image containing four Gaussian sources on a noisy background. The image is deterministic and must be treated as read-only by tests. """ return make_4gaussians_image() @pytest.fixture(name='unit_data') def fixture_unit_data(): """ A small uniform image of ones. A fresh array is returned for each test, so it may be modified. """ return np.ones(UNIT_SHAPE) @pytest.fixture(name='unit_mask') def fixture_unit_mask(): """ An all-`False` boolean mask matching the ``unit_data`` shape. A fresh array is returned for each test, so it may be modified. """ return np.zeros(UNIT_SHAPE, dtype=bool) @pytest.fixture def tan_wcs(): """ Return a simple gnomonic (TAN) WCS used for aperture round-trip tests. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [50.5, 50.5] wcs.wcs.cdelt = [-0.001, 0.001] wcs.wcs.crval = [10.0, 30.0] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs astropy-photutils-24be529/photutils/aperture/tests/test_aperture_common.py000066400000000000000000000150001526452531300274400ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Base classes for aperture tests. The shape-specific test modules (e.g., ``test_circle``, ``test_ellipse``, ``test_rectangle``) define one class per aperture class. Each of those classes sets the ``aperture`` attribute (a multiposition aperture) and the ``copy_param``/``copy_value`` pair naming a parameter to change, and inherits the tests that apply to every aperture of that kind from the base classes here. """ import astropy.units as u import pytest from astropy.coordinates import SkyCoord from astropy.tests.helper import assert_quantity_allclose from numpy.testing import assert_equal from photutils.utils._optional_deps import HAS_MATPLOTLIB class BaseTestApertureParams: index = 2 slc = slice(0, 2) expected_slc_len = 2 class BaseTestAperture(BaseTestApertureParams): """ Tests that apply to every aperture, pixel or sky. """ # The name and new value of an aperture parameter to change in # test_copy_eq copy_param = None copy_value = None 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)) def test_copy_eq(self): """ Test that a copy compares equal until one of its parameters is changed. """ aper = self.aperture.copy() assert aper == self.aperture setattr(aper, self.copy_param, self.copy_value) assert aper != self.aperture class BaseTestPixelAperture(BaseTestAperture): """ Tests that apply to every pixel aperture. """ @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): """ Test that plot returns a list of patches usable in a legend. """ from matplotlib import pyplot as plt from matplotlib.patches import Patch patches = self.aperture.plot() assert isinstance(patches, list) for patch in patches: assert isinstance(patch, Patch) _, ax = plt.subplots() ax.legend(patches, list(range(len(patches)))) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_scalar_returns_list(self): """ Test that plot returns a list containing a single patch for a scalar aperture. """ from matplotlib.patches import Patch patches = self.aperture[0].plot() assert isinstance(patches, list) assert len(patches) == 1 assert isinstance(patches[0], Patch) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_to_patch_nonscalar(self): """ Test that _to_patch returns a list for nonscalar apertures. """ patches = self.aperture._to_patch() assert isinstance(patches, list) class BaseTestRotatedPixelAperture(BaseTestPixelAperture): """ Tests that apply to every pixel aperture with a rotation angle. """ def test_theta(self): """ Test that a theta input as a float is stored as a Quantity in radians. """ assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad class BaseTestAnnulusMutation: """ Tests for the inner/outer parameter ordering invariants of annulus apertures, mixed into the annulus test classes. """ def test_ordered_pairs_mutation(self): """ Test that reassigning a parameter of an inner/outer pair to a value violating the ordering invariant raises a ValueError and leaves the aperture unchanged. """ aper = self.aperture.copy() for inner, outer in aper._ordered_pairs: inner_value = getattr(aper, inner) outer_value = getattr(aper, outer) match = f'{outer!r} must be greater than {inner!r}' with pytest.raises(ValueError, match=match): setattr(aper, inner, outer_value) with pytest.raises(ValueError, match=match): setattr(aper, inner, outer_value * 2) with pytest.raises(ValueError, match=match): setattr(aper, outer, inner_value / 2) assert getattr(aper, inner) == inner_value assert getattr(aper, outer) == outer_value def test_ordered_pairs_valid_mutation(self): """ Test that reassigning inner/outer pair parameters to values satisfying the ordering invariant works. """ aper = self.aperture.copy() for inner, outer in aper._ordered_pairs: new_outer = getattr(aper, outer) * 2 new_inner = getattr(aper, inner) * 1.5 setattr(aper, outer, new_outer) setattr(aper, inner, new_inner) assert getattr(aper, inner) == new_inner assert getattr(aper, outer) == new_outer astropy-photutils-24be529/photutils/aperture/tests/test_attributes.py000066400000000000000000000373411526452531300264430ustar00rootroot00000000000000# 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 MockWithCachedProperties: """ Minimal class with _cached_properties for reset testing. """ attr = ApertureAttribute() _cached_properties = ['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_cached_properties(self): """ Test that cached properties are cleared when the attribute is updated. """ obj = MockWithCachedProperties() obj.attr = 1.0 obj.cached = 42 assert 'cached' in obj.__dict__ obj.attr = 2.0 # second assignment triggers _reset_cached_properties assert 'cached' not in obj.__dict__ def test_reset_no_cached_properties(self): """ Test that AttributeError is silently caught when _cached_properties is absent. """ obj = MockBase() obj.attr = 1.0 # Triggers _reset_cached_properties, but obj has no _cached_properties 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_cached_properties(self): """ Test that setting positions twice clears cached properties. """ obj = MockPixelPos() obj.positions = [(1, 2)] obj._cached_properties = ['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 nonfinite" 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 nonfinite" 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 nonscalar 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]) def test_non_numeric_error(self): """ Test that a nonnumeric scalar (e.g., a string) raises TypeError with the intended message. """ obj = MockPositiveScalar() match = "'r' must be a positive scalar" with pytest.raises(TypeError, match=match): obj.r = 'abc' def test_nan_error(self): """ Test that NaN raises ValueError. """ obj = MockPositiveScalar() match = "'r' must be a positive scalar" with pytest.raises(ValueError, match=match): obj.r = np.nan 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 nonscalar 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 nonangular 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 nonscalar 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_nan_error(self): """ Test that a NaN angle raises ValueError. """ obj = MockPositiveScalarAngle() match = "'angle' must be greater than zero" with pytest.raises(ValueError, match=match): obj.angle = np.nan * u.arcsec def test_non_angle_units_error(self): """ Test that a Quantity with nonangular 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_cached_properties(self): """ Test that setting theta twice clears cached properties. """ obj = MockScalarAngleOrValue() obj.theta = 1.0 obj._cached_properties = ['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 nonscalar 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 nonangular 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 nonscalar 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-24be529/photutils/aperture/tests/test_batch_flag_counts.py000066400000000000000000000446171526452531300277260ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the per-source flag counts returned by the batch drivers. """ import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.aperture import CircularAperture from photutils.aperture._batch_photometry import (FLAG_COL_BBOX_CLIPPED, FLAG_COL_MASKED, FLAG_COL_N_PIXELS, FLAG_COL_NONFINITE_DATA, FLAG_COL_NONFINITE_ERROR, FLAG_COL_SEG, FLAG_COL_SEG_MASKED, FLAG_COL_UNCORRECTED, FLAG_COL_UNCORRECTED_MASKED, FLAG_COL_VALID, N_FLAG_COLS, SHAPE_CIRCLE, batch_aperture_sums) from photutils.aperture._batch_stats import batch_aperture_gather from photutils.aperture.tests.conftest import UNIT_SHAPE def _sums_fcounts(data, positions, radius, *, error=None, mask=None, use_exact=1, subpixels=5, segmentation=None, labels=None, seg_method=0, mask_nonfinite=0): """ Run ``batch_aperture_sums`` and return the flag-count array. """ positions = np.ascontiguousarray(np.atleast_2d(positions), dtype=np.float64) params = np.array([radius], dtype=np.float64) result = batch_aperture_sums( np.ascontiguousarray(data, dtype=np.float64), error, mask, positions, SHAPE_CIRCLE, params, radius, radius, 0.0, 0.0, use_exact, subpixels, segmentation, labels, seg_method, mask_nonfinite=mask_nonfinite) return result.flag_counts def _gather_fcounts(data, positions, radius, *, mask=None, segmentation=None, labels=None, seg_method=0, mask_nonfinite=0): """ Run ``batch_aperture_gather`` and return the flag-count array. """ positions = np.ascontiguousarray(np.atleast_2d(positions), dtype=np.float64) params = np.array([radius], dtype=np.float64) result = batch_aperture_gather( np.ascontiguousarray(data, dtype=np.float64), mask, positions, SHAPE_CIRCLE, params, radius, radius, 0.0, 0.0, None, segmentation, labels, seg_method, mask_nonfinite=mask_nonfinite) return result[-1] class TestPixelCounts: """ Tests for the per-source pixel counts returned by the batch drivers. """ def test_interior_source(self, unit_data): """ Test that a clean interior source has only n_pixels/valid counts. """ data = unit_data fc = _sums_fcounts(data, (12.0, 12.0), 3.0)[0] assert fc[FLAG_COL_N_PIXELS] > 0 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] assert fc[FLAG_COL_MASKED] == 0 assert fc[FLAG_COL_NONFINITE_DATA] == 0 assert fc[FLAG_COL_NONFINITE_ERROR] == 0 assert fc[FLAG_COL_SEG] == 0 assert fc[FLAG_COL_UNCORRECTED] == 0 assert fc[FLAG_COL_BBOX_CLIPPED] == 0 def test_no_bbox_overlap(self, unit_data): """ Test that a source with no bounding-box overlap has all-zero counts. """ data = unit_data fc = _sums_fcounts(data, (100.0, 100.0), 3.0)[0] assert_array_equal(fc, 0) fc = _gather_fcounts(data, (100.0, 100.0), 3.0)[0] assert_array_equal(fc, 0) @pytest.mark.parametrize('use_exact', [0, 1]) def test_masked_pixel_membership(self, unit_data, use_exact): """ Test that only masked pixels with nonzero overlap fraction are counted. """ data = unit_data # Pixel inside the aperture mask = np.zeros(UNIT_SHAPE, dtype=np.uint8) mask[12, 12] = 1 fc = _sums_fcounts(data, (12.0, 12.0), 3.0, mask=mask, use_exact=use_exact)[0] assert fc[FLAG_COL_MASKED] == 1 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] - 1 # Pixel inside the bounding box but outside the aperture (bbox # corner) mask = np.zeros(UNIT_SHAPE, dtype=np.uint8) mask[9, 9] = 1 fc = _sums_fcounts(data, (12.0, 12.0), 3.0, mask=mask, use_exact=use_exact)[0] assert fc[FLAG_COL_MASKED] == 0 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] def test_mask_plane_bits(self, unit_data): """ Test that mask-plane bit 1 counts as masked and bit 2 as nonfinite data, with bit 1 taking precedence. """ data = unit_data mask = np.zeros(UNIT_SHAPE, dtype=np.uint8) mask[12, 12] = 1 # input-masked mask[12, 13] = 2 # nonfinite data mask[13, 12] = 3 # both, masked wins for func in (_sums_fcounts, _gather_fcounts): fc = func(data, (12.0, 12.0), 3.0, mask=mask)[0] assert fc[FLAG_COL_MASKED] == 2 assert fc[FLAG_COL_NONFINITE_DATA] == 1 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] - 3 def test_gather_matches_sums_center(self): """ Test that the gather kernel flag counts match the photometry kernel with the "center" method. """ rng = np.random.default_rng(1) data = rng.normal(size=UNIT_SHAPE) data[11, 12] = np.nan user_mask = np.zeros(UNIT_SHAPE, dtype=bool) user_mask[12, 13] = True xy = np.array([(12.0, 12.0), (0.5, 3.0), (24.0, 24.0)]) # 2-bit plane: bit 1 = user mask, bit 2 = nonfinite data plane = user_mask.astype(np.uint8) plane |= (~np.isfinite(data) & ~user_mask) * np.uint8(2) fc_sums = _sums_fcounts(data, xy, 3.0, mask=plane, use_exact=0, subpixels=1) fc_gather = _gather_fcounts(data, xy, 3.0, mask=plane) # The gather kernel never reads error values cols = [FLAG_COL_N_PIXELS, FLAG_COL_MASKED, FLAG_COL_NONFINITE_DATA, FLAG_COL_SEG, FLAG_COL_UNCORRECTED, FLAG_COL_VALID, FLAG_COL_BBOX_CLIPPED] assert_array_equal(fc_gather[:, cols], fc_sums[:, cols]) class TestNonFiniteCounts: """ Tests for the nonfinite data and error indicator counts. """ def test_nonfinite_data_unmasked(self, unit_data): """ Test that unmasked nonfinite data values are detected (and still contribute) in the photometry kernel. Unmasked nonfinite contributions are detected from the accumulated sums as a 0/1 indicator. """ data = unit_data data[12, 12] = np.nan data[12, 13] = np.inf fc = _sums_fcounts(data, (12.0, 12.0), 3.0)[0] assert fc[FLAG_COL_NONFINITE_DATA] == 1 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] def test_nonfinite_error(self, unit_data): """ Test that nonfinite error values among contributing pixels are counted. """ data = unit_data error = np.ones(UNIT_SHAPE) error[12, 12] = np.nan fc = _sums_fcounts(data, (12.0, 12.0), 3.0, error=error)[0] assert fc[FLAG_COL_NONFINITE_ERROR] == 1 # A masked pixel with nonfinite error is not counted mask = np.zeros(UNIT_SHAPE, dtype=np.uint8) mask[12, 12] = 1 fc = _sums_fcounts(data, (12.0, 12.0), 3.0, error=error, mask=mask)[0] assert fc[FLAG_COL_NONFINITE_ERROR] == 0 class TestMaskNonfinite: """ Tests for the per-pixel nonfinite data handling of the batch drivers (``mask_nonfinite``). """ @staticmethod def _inputs(): """ Return the data, segmentation, and labels of a source with one neighbor-segment pixel whose mirror pixel is nonfinite. """ data = np.ones((11, 11)) data[5, 2] = np.nan # mirror of (5, 8) across the center segm = np.zeros((11, 11), dtype=np.intp) segm[4:7, 4:7] = 1 segm[5, 8] = 2 # neighbor pixel labels = np.array([1], dtype=np.intp) return data, segm, labels @pytest.mark.parametrize('func', [_sums_fcounts, _gather_fcounts]) def test_matches_mask_plane_bit(self, func): """ Test that the per-pixel test gives the same counts as marking the nonfinite pixels in the mask plane (bit 2), with an input-masked nonfinite pixel counted only as masked. """ data = np.ones(UNIT_SHAPE) data[12, 13] = np.nan data[11, 12] = np.inf data[13, 12] = np.nan # also input-masked mask = np.zeros(UNIT_SHAPE, dtype=np.uint8) mask[13, 12] = 1 mask[12, 12] = 1 fc = func(data, (12.0, 12.0), 3.0, mask=mask, mask_nonfinite=1)[0] assert fc[FLAG_COL_MASKED] == 2 assert fc[FLAG_COL_NONFINITE_DATA] == 2 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] - 4 plane = mask.copy() plane[~np.isfinite(data) & (mask == 0)] = 2 fc_plane = func(data, (12.0, 12.0), 3.0, mask=plane)[0] assert_array_equal(fc, fc_plane) @pytest.mark.parametrize('func', [_sums_fcounts, _gather_fcounts]) def test_nonfinite_mirror(self, func): """ Test that a neighbor pixel whose mirror pixel is nonfinite is counted as uncorrected by the 'correct' method. """ data, segm, labels = self._inputs() kwargs = {'segmentation': segm, 'labels': labels, 'seg_method': 4} fc = func(data, (5.0, 5.0), 4.0, mask_nonfinite=1, **kwargs)[0] assert fc[FLAG_COL_NONFINITE_DATA] == 1 assert fc[FLAG_COL_SEG] == 1 assert fc[FLAG_COL_UNCORRECTED] == 1 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] - 2 # A finite mirror pixel corrects the neighbor pixel data[5, 2] = 1.0 fc = func(data, (5.0, 5.0), 4.0, mask_nonfinite=1, **kwargs)[0] assert fc[FLAG_COL_SEG] == 1 assert fc[FLAG_COL_UNCORRECTED] == 0 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] @pytest.mark.parametrize('func', [_sums_fcounts, _gather_fcounts]) def test_nonfinite_mirror_masked_neighbor(self, func): """ Test that an input-masked neighbor pixel whose mirror pixel is nonfinite is counted in the masked uncorrected column. """ data, segm, labels = self._inputs() mask = np.zeros((11, 11), dtype=np.uint8) mask[5, 8] = 1 # the neighbor pixel fc = func(data, (5.0, 5.0), 4.0, mask=mask, segmentation=segm, labels=labels, seg_method=4, mask_nonfinite=1)[0] assert fc[FLAG_COL_MASKED] == 1 assert fc[FLAG_COL_NONFINITE_DATA] == 1 assert fc[FLAG_COL_SEG] == 0 assert fc[FLAG_COL_UNCORRECTED] == 0 assert fc[FLAG_COL_SEG_MASKED] == 1 assert fc[FLAG_COL_UNCORRECTED_MASKED] == 1 class TestBboxClipped: """ Tests for the bounding-box-clipped candidate indicator. """ @pytest.mark.parametrize('use_exact', [0, 1]) def test_indicator(self, unit_data, use_exact): """ Test the bbox-clipped indicator for interior and edge sources. """ data = unit_data # Interior source fc = _sums_fcounts(data, (12.0, 12.0), 3.0, use_exact=use_exact)[0] assert fc[FLAG_COL_BBOX_CLIPPED] == 0 # Aperture straddling the left edge fc = _sums_fcounts(data, (0.0, 12.0), 3.0, use_exact=use_exact)[0] assert fc[FLAG_COL_BBOX_CLIPPED] == 1 assert fc[FLAG_COL_N_PIXELS] > 0 # Clipped bbox whose "center"-method weights are all inside the data # (the caller resolves the precise outside-weight test) fc = _sums_fcounts(data, (0.2, 12.0), 0.8, use_exact=0, subpixels=1)[0] assert fc[FLAG_COL_BBOX_CLIPPED] == 1 assert fc[FLAG_COL_N_PIXELS] > 0 def test_parity_with_mask(self, unit_data): """ Test that n_pixels matches the nonzero in-data aperture-mask weights and that the bbox-clipped indicator matches the overlap slices, for randomized positions including edge and rounding cases. """ rng = np.random.default_rng(0) data = unit_data n_src = 50 xy = rng.uniform(-6.0, 30.0, size=(n_src, 2)) # Include exact half-integer rounding cases xy[:5] = [(0.5, 0.5), (-0.5, 12.0), (24.5, 24.5), (0.0, 0.0), (12.5, -0.5)] radius = 2.5 for use_exact, method in ((1, 'exact'), (0, 'center')): fcs = _sums_fcounts(data, xy, radius, use_exact=use_exact, subpixels=1) apertures = CircularAperture(xy, r=radius) masks = apertures.to_mask(method=method) for fc, apermask in zip(fcs, masks, strict=True): slc_large, slc_small = apermask.get_overlap_slices(UNIT_SHAPE) if slc_large is None: assert_array_equal(fc, 0) continue n_in = np.count_nonzero(apermask.data[slc_small]) assert fc[FLAG_COL_N_PIXELS] == n_in full = (slice(0, apermask.data.shape[0]), slice(0, apermask.data.shape[1])) assert fc[FLAG_COL_BBOX_CLIPPED] == int(slc_small != full) class TestSegmentationCounts: """ Tests for the segmentation neighbor and uncorrected pixel counts. """ def test_seg_counts(self, unit_data): """ Test the segmentation-affected pixel counts for the mask, source_only, and correct methods. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=np.intp) segm[10:15, 10:15] = 1 segm[12, 14] = 2 # neighbor pixel inside the aperture labels = np.array([1], dtype=np.intp) # Method 1 ('mask'): neighbor pixels are excluded fc = _sums_fcounts(data, (12.0, 12.0), 3.0, segmentation=segm, labels=labels, seg_method=1)[0] assert fc[FLAG_COL_SEG] == 1 assert fc[FLAG_COL_UNCORRECTED] == 0 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] - 1 # Method 2 ('source_only'): background exclusions are not counted # as neighbor pixels fc = _sums_fcounts(data, (12.0, 12.0), 3.0, segmentation=segm, labels=labels, seg_method=2)[0] assert fc[FLAG_COL_SEG] == 1 # Method 3 ('background_only'): every labeled pixel is excluded # and counted as a neighbor pixel fc = _sums_fcounts(data, (12.0, 12.0), 3.0, segmentation=segm, labels=labels, seg_method=3)[0] fc_ref = _sums_fcounts(data, (12.0, 12.0), 3.0, mask=(segm > 0).astype(np.uint8))[0] assert fc[FLAG_COL_SEG] == fc_ref[FLAG_COL_MASKED] assert fc[FLAG_COL_SEG] > 1 assert fc[FLAG_COL_UNCORRECTED] == 0 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] - fc[FLAG_COL_SEG] # Method 4 ('correct'): the neighbor pixel is corrected (mirror # pixel is valid) fc = _sums_fcounts(data, (12.0, 12.0), 3.0, segmentation=segm, labels=labels, seg_method=4)[0] assert fc[FLAG_COL_SEG] == 1 assert fc[FLAG_COL_UNCORRECTED] == 0 assert fc[FLAG_COL_VALID] == fc[FLAG_COL_N_PIXELS] # Method 4 with the mirror pixel also a neighbor: uncorrectable segm2 = segm.copy() segm2[12, 10] = 2 # mirror of (12, 14) across (12, 12) fc = _sums_fcounts(data, (12.0, 12.0), 3.0, segmentation=segm2, labels=labels, seg_method=4)[0] assert fc[FLAG_COL_SEG] == 2 assert fc[FLAG_COL_UNCORRECTED] == 2 # Method 4 with a masked mirror pixel: uncorrectable mask = np.zeros(UNIT_SHAPE, dtype=np.uint8) mask[12, 10] = 1 fc = _sums_fcounts(data, (12.0, 12.0), 3.0, mask=mask, segmentation=segm, labels=labels, seg_method=4)[0] assert fc[FLAG_COL_SEG] == 1 assert fc[FLAG_COL_UNCORRECTED] == 1 class TestSegMaskedCounts: """ Tests for the masked neighbor-segment pixel counts. Masked pixels never reach the segmentation branch of the per-pixel loop, so they are counted in separate columns for callers that treat the mask and neighbor overlays independently. """ @staticmethod def _inputs(): """ Return the data, mask, segmentation, and labels of a source with one unmasked and one masked neighbor-segment pixel. """ data = np.ones((11, 11)) segm = np.zeros((11, 11), dtype=np.intp) segm[4:7, 4:7] = 1 segm[5, 8] = 2 # neighbor pixel, unmasked # Neighbor pixel that is masked. Its mirror across the center is # (5, 8), which is also a neighbor, so neither pixel can be # corrected by the 'correct' method. segm[5, 2] = 2 mask = np.zeros((11, 11), dtype=np.uint8) mask[5, 2] = 1 labels = np.array([1], dtype=np.intp) return data, mask, segm, labels def test_n_flag_cols(self): """ Test the flag-count column layout. """ assert FLAG_COL_SEG_MASKED == 8 assert FLAG_COL_UNCORRECTED_MASKED == 9 assert N_FLAG_COLS == 10 @pytest.mark.parametrize('func', [_sums_fcounts, _gather_fcounts]) @pytest.mark.parametrize(('seg_method', 'n_seg', 'n_uncorr', 'n_seg_masked', 'n_uncorr_masked'), [(4, 1, 1, 1, 1), (1, 1, 0, 1, 0), (0, 0, 0, 0, 0)]) def test_seg_masked_columns(self, func, seg_method, n_seg, n_uncorr, n_seg_masked, n_uncorr_masked): """ Test that masked neighbor-segment pixels are counted separately from the unmasked ones by both batch drivers. """ data, mask, segm, labels = self._inputs() fc = func(data, (5.0, 5.0), 4.0, mask=mask, segmentation=segm, labels=labels, seg_method=seg_method)[0] assert fc.shape == (N_FLAG_COLS,) assert fc[FLAG_COL_MASKED] == 1 assert fc[FLAG_COL_SEG] == n_seg assert fc[FLAG_COL_UNCORRECTED] == n_uncorr assert fc[FLAG_COL_SEG_MASKED] == n_seg_masked assert fc[FLAG_COL_UNCORRECTED_MASKED] == n_uncorr_masked astropy-photutils-24be529/photutils/aperture/tests/test_batch_minmax.py000066400000000000000000000121261526452531300267010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch minimum/maximum kernel and the sort-free ApertureStats ``min`` and ``max`` path. """ from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose, assert_array_equal from photutils.aperture import ApertureStats, CircularAperture from photutils.aperture._batch_stats import batch_minmax def _packed(rng, counts): starts = np.concatenate(([0], np.cumsum(counts)[:-1])).astype(np.intp) values = rng.normal(size=int(counts.sum())) return values, starts, counts.astype(np.intp) def test_kernel_matches_numpy(): rng = np.random.default_rng(3) counts = rng.integers(0, 40, 300) counts[::7] = 0 counts[1::11] = 1 values, starts, counts = _packed(rng, counts) vmin, vmax = batch_minmax(values, starts, counts) for k, (start, count) in enumerate(zip(starts, counts, strict=True)): if count == 0: assert np.isnan(vmin[k]) assert np.isnan(vmax[k]) else: chunk = values[start:start + count] assert vmin[k] == chunk.min() assert vmax[k] == chunk.max() def test_kernel_single_value_and_duplicates(): values = np.array([2.0, 5.0, 5.0, -1.0, -1.0, 7.0]) starts = np.array([0, 1, 3], dtype=np.intp) counts = np.array([1, 2, 3], dtype=np.intp) vmin, vmax = batch_minmax(values, starts, counts) assert_array_equal(vmin, [2.0, 5.0, -1.0]) assert_array_equal(vmax, [2.0, 5.0, 7.0]) def test_kernel_thread_safety(): rng = np.random.default_rng(4) values, starts, counts = _packed(rng, rng.integers(1, 30, 200)) expected = batch_minmax(values, starts, counts) def run(_): return batch_minmax(values, starts, counts) with ThreadPoolExecutor(max_workers=4) as pool: for result in pool.map(run, range(8)): for got, want in zip(result, expected, strict=True): assert_array_equal(got, want) @pytest.fixture def stats_inputs(): rng = np.random.default_rng(5) data = rng.normal(size=(120, 120)) positions = np.column_stack((rng.uniform(5, 115, 60), rng.uniform(5, 115, 60))) return data, CircularAperture(positions, r=4.0) def _reference_extremes(data, aperture): vmin = [] vmax = [] for mask in aperture.to_mask(method='center'): cutout = mask.get_values(data) vmin.append(cutout.min()) vmax.append(cutout.max()) return np.array(vmin), np.array(vmax) @pytest.mark.parametrize('n_threads', [1, 3]) def test_min_max_without_sort(stats_inputs, n_threads): """ Test that min and max alone are computed without the per-source sort and match both the reference and the sorted path. """ data, aperture = stats_inputs ref_min, ref_max = _reference_extremes(data, aperture) stats = ApertureStats(data, aperture, n_threads=n_threads) assert stats._fast_gather is not None assert_allclose(stats.min, ref_min) assert_allclose(stats.max, ref_max) assert '_sorted_values' not in stats.__dict__ assert '_order_stats' not in stats.__dict__ # The sorted path gives the same values vmin, vmax, _ = stats._order_stats assert_array_equal(vmin, stats.min) assert_array_equal(vmax, stats.max) def test_min_max_after_sort(stats_inputs): """ Test that min and max agree with the sorted path (and the reference) when the sorted buffer already exists. """ data, aperture = stats_inputs stats = ApertureStats(data, aperture) median = stats.median assert '_sorted_values' in stats.__dict__ assert_array_equal(stats._minmax[0], stats._order_stats[0]) assert_array_equal(stats._minmax[1], stats._order_stats[1]) ref_min, ref_max = _reference_extremes(data, aperture) assert_allclose(stats.min, ref_min) assert_allclose(stats.max, ref_max) assert np.all(stats.min <= median) assert np.all(median <= stats.max) def test_min_max_with_sigma_clip(stats_inputs): """ Test that the sigma-clipped min and max are the extremes of the surviving values (the clipping kernel's sorted buffer). """ data, aperture = stats_inputs sigma_clip = SigmaClip(sigma=2.0, maxiters=5) stats = ApertureStats(data, aperture, sigma_clip=sigma_clip) assert stats._fast_gather.sorted_values is not None assert_array_equal(stats._minmax[0], stats._order_stats[0]) assert_array_equal(stats._minmax[1], stats._order_stats[1]) vmin = [] vmax = [] for mask in aperture.to_mask(method='center'): clipped = sigma_clip(mask.get_values(data), masked=False) vmin.append(clipped.min()) vmax.append(clipped.max()) assert_allclose(stats.min, vmin) assert_allclose(stats.max, vmax) def test_min_max_scalar_and_unit(): data = np.arange(400.0).reshape(20, 20) aperture = CircularAperture((10.0, 10.0), r=2.5) stats = ApertureStats(data, aperture) values = aperture.to_mask(method='center').get_values(data) assert stats.min == values.min() assert stats.max == values.max() astropy-photutils-24be529/photutils/aperture/tests/test_batch_photometry.py000066400000000000000000000371371526452531300276330ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch Cython aperture photometry driver, which must give results identical to the mask-based code path. """ from concurrent.futures import ThreadPoolExecutor import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose from photutils.aperture.circle import CircularAnnulus, CircularAperture from photutils.aperture.core import PixelAperture, _enable_batch_photometry from photutils.aperture.ellipse import EllipticalAnnulus, EllipticalAperture from photutils.aperture.polygon import PolygonAperture from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture) RNG = np.random.default_rng(0) SHAPE = (157, 163) DATA = RNG.normal(100.0, 5.0, SHAPE) ERROR = RNG.uniform(1.0, 2.0, SHAPE) MASK = RNG.uniform(size=SHAPE) < 0.05 DATA_NAN = DATA.copy() DATA_NAN[::11, ::13] = np.nan # Positions covering interior, subpixel offsets, partial overlap with # every data edge, no overlap, and far outside the data POSITIONS = np.array([[70.0, 80.0], [50.3, 71.8], [20.49999, 30.50001], [0.0, 0.0], [162.0, 156.0], [-3.0, 80.0], [166.0, 80.0], [80.0, -3.0], [80.0, 160.0], [-50.0, -50.0], [250.0, 250.0], [1e9, -1e9]]) APERTURES = [ CircularAperture(POSITIONS, r=5.5), CircularAperture(POSITIONS, r=0.4), CircularAnnulus(POSITIONS, r_in=4.0, r_out=6.0), EllipticalAperture(POSITIONS, a=5.0, b=3.0, theta=0.7), EllipticalAnnulus(POSITIONS, a_in=3.0, a_out=6.0, b_out=4.0, theta=-1.1), RectangularAperture(POSITIONS, w=7.0, h=4.0, theta=0.4), RectangularAnnulus(POSITIONS, w_in=3.0, w_out=8.0, h_out=5.0, theta=2.2), PolygonAperture.from_regular_polygon(POSITIONS, 6, radius=5.5), PolygonAperture(POSITIONS, [[-4.0, -3.0], [6.0, -2.0], [3.0, 5.0], [-5.0, 4.0]]), # A nonconvex (concave arrowhead) polygon offset shape PolygonAperture(POSITIONS, [[-5.0, -4.0], [5.0, -4.0], [0.0, 6.0], [0.0, 0.0]]), ] METHODS = [('exact', 5), ('center', 5), ('subpixel', 1), ('subpixel', 7)] def assert_batch_matches_legacy(aperture, data, *, error=None, mask=None, method='exact', subpixels=5): """ Test that the batch photometry driver gives results identical to the legacy mask-based code path for the given inputs. """ batch = aperture._batch_photometry(data, error=error, mask=mask, method=method, subpixels=subpixels) assert batch is not None legacy = aperture._mask_photometry(data, error=error, mask=mask, method=method, subpixels=subpixels) for batch_arr, legacy_arr in zip(batch, legacy, strict=True): assert batch_arr.shape == legacy_arr.shape assert_allclose(batch_arr, legacy_arr, rtol=1e-12, atol=0, equal_nan=True) class _ErrorSubclass(np.ndarray): """ An ndarray subclass, which the batch driver does not accept because it may override the array behavior it relies on. """ class TestMaskPathParity: """ The batch driver must give results identical to the mask-based code path. """ @pytest.mark.parametrize('aperture', APERTURES) @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_basic(self, aperture, method, subpixels): """ Test that the batch photometry driver gives results identical to the legacy mask-based code path for the given inputs. """ assert_batch_matches_legacy(aperture, DATA, method=method, subpixels=subpixels) @pytest.mark.parametrize('aperture', APERTURES) @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_error_and_mask(self, aperture, method, subpixels): """ Test that the batch photometry driver gives results identical to the legacy mask-based code path for the given inputs, including error and mask. """ assert_batch_matches_legacy(aperture, DATA, error=ERROR, mask=MASK, method=method, subpixels=subpixels) @pytest.mark.parametrize('aperture', APERTURES) def test_nan_data(self, aperture): """ Test that the batch photometry driver gives results identical to the legacy mask-based code path for data containing NaN values. """ assert_batch_matches_legacy(aperture, DATA_NAN, error=ERROR) @pytest.mark.parametrize('dtype', ['int32', 'uint16', 'float32', 'bool']) def test_data_dtypes(self, dtype): """ Test that the batch photometry driver gives results identical to the legacy mask-based code path for different data dtypes, including bool. """ rtol = 1e-5 if dtype == 'float32' else 1e-12 aperture = CircularAperture(POSITIONS, r=5.5) data = (DATA > 100.0) if dtype == 'bool' else DATA.astype(dtype) batch = aperture._batch_photometry(data, error=None, mask=None, method='exact', subpixels=5) legacy = aperture._mask_photometry(data, error=None, mask=None, method='exact', subpixels=5) assert batch is not None assert_allclose(batch[0], legacy[0], rtol=rtol, equal_nan=True) def test_scalar_position(self): """ Test that the batch photometry driver gives results identical to the legacy mask-based code path for a single scalar position. """ aperture = CircularAperture((70.0, 80.0), r=5.5) assert_batch_matches_legacy(aperture, DATA, error=ERROR) aperture = EllipticalAperture((70.0, 80.0), 10.124, 5.073) assert_batch_matches_legacy(aperture, DATA, error=ERROR) def test_units(self): """ Test that the batch photometry driver gives results with the same units as the input data and error, and that the values match the legacy code path. """ unit = u.Jy aperture = CircularAperture(POSITIONS, r=5.5) results = aperture._photometry(DATA << unit, error=ERROR << unit) assert results.flux.unit == unit assert results.flux_err.unit == unit results2 = aperture._photometry(DATA, error=ERROR) assert_allclose(results.flux.value, results2.flux, equal_nan=True) assert_allclose(results.flux_err.value, results2.flux_err, equal_nan=True) class TestReadonlyArrays: """ Read-only input arrays must be accepted by the batch driver. """ @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_batch_driver(self, method, subpixels): """ Test that read-only (nonwritable) data, error, and mask arrays are accepted by the batch photometry driver and give results identical to writable arrays. The batch Cython driver buffers the data, error, and positions into ``const`` typed memoryviews so that read-only arrays do not raise a ``ValueError``. """ aperture = CircularAperture(POSITIONS, r=5.5) data = DATA.copy() error = ERROR.copy() mask = MASK.copy() for arr in (data, error, mask): arr.setflags(write=False) batch = aperture._batch_photometry(data, error=error, mask=mask, method=method, subpixels=subpixels) assert batch is not None expected = aperture._batch_photometry(DATA, error=ERROR, mask=MASK, method=method, subpixels=subpixels) for batch_arr, exp_arr in zip(batch, expected, strict=True): assert_allclose(batch_arr, exp_arr, rtol=1e-12, atol=0, equal_nan=True) def test_photometry(self): """ Test that ``_photometry`` works with read-only data arrays. """ data = np.ones((10, 10)) data.setflags(write=False) aperture = CircularAperture((4.5, 4.5), r=4.5) results = aperture._photometry(data, method='exact') expected = aperture._photometry(np.ones((10, 10)), method='exact') assert_allclose(results.flux, expected.flux, rtol=1e-12) class TestErrorArrayShape: """ The flux errors must always have the same shape as the fluxes. """ def test_no_overlap(self): """ Test that sources with no overlap with the data give NaN sums. Also, test that the error array always has the same shape as the sums array, filled with NaN when error is not input. """ aperture = CircularAperture(POSITIONS, r=5.5) n_outside = 3 sums, errs, _area, *_ = aperture._batch_photometry( DATA, error=None, mask=None, method='exact', subpixels=5) assert np.isnan(sums).sum() == n_outside assert errs.shape == sums.shape assert np.all(np.isnan(errs)) sums, errs, _area, *_ = aperture._batch_photometry( DATA, error=ERROR, mask=None, method='exact', subpixels=5) assert errs.shape == sums.shape assert np.isnan(errs).sum() == n_outside @pytest.mark.parametrize('aperture', [ CircularAperture(POSITIONS, r=5.5), PolygonAperture.from_regular_polygon(POSITIONS, 6, radius=5.5), ]) def test_no_error_input(self, aperture): """ Test that `photometry` returns a ``flux_err`` array that always has the same length as ``flux`` and is filled with NaN when ``error`` is not input, both for sources with and without overlap. ``CircularAperture`` exercises the batch code path and ``PolygonAperture`` exercises the mask-based code path. """ result = aperture._photometry(DATA, error=None) assert result.flux_err.shape == result.flux.shape assert np.any(np.isnan(result.flux)) assert np.any(~np.isnan(result.flux)) assert np.all(np.isnan(result.flux_err)) class TestFallback: """ Inputs and apertures that the batch driver does not support must fall back to the mask-based code path. """ def test_unsupported_inputs(self): """ Test that inputs not supported by the batch photometry driver cause it to return None so that the caller falls back to the mask-based code path. """ aperture = CircularAperture(POSITIONS, r=5.5) # Masked-array data data = np.ma.MaskedArray(DATA, mask=MASK) assert aperture._batch_photometry(data, error=None, mask=None, method='exact', subpixels=5) is None # Non-bool mask assert aperture._batch_photometry(DATA, error=None, mask=MASK.astype(int), method='exact', subpixels=5) is None # Mask with mismatched shape assert aperture._batch_photometry(DATA, error=None, mask=MASK[1:, :], method='exact', subpixels=5) is None # Unsupported data dtype assert aperture._batch_photometry(DATA.astype(complex), error=None, mask=None, method='exact', subpixels=5) is None # Unsupported error dtype assert aperture._batch_photometry(DATA, error=ERROR.astype(complex), mask=None, method='exact', subpixels=5) is None def test_error_subclass_matches_mask_path(self): """ Test that an ``error`` array that the batch driver does not accept gives the same results via the mask-based code path. """ aperture = CircularAperture(POSITIONS, r=5.5) error = ERROR.view(_ErrorSubclass) assert aperture._batch_photometry(DATA, error=error, mask=None, method='exact', subpixels=5) is None result = aperture._photometry(DATA, error=error) expected = aperture._photometry(DATA, error=ERROR) assert_allclose(result.flux, expected.flux, rtol=1e-12, equal_nan=True) assert_allclose(result.flux_err, expected.flux_err, rtol=1e-12, equal_nan=True) def test_undecorated_subclass(self): """ Test that aperture subclasses that are not decorated with _enable_batch_photometry fall back to the mask-based code path in photometry, so that any overridden behavior (e.g., to_mask) is honored. """ class MyAperture(CircularAperture): # Inherits _batch_photometry_class pointing at # CircularAperture, so the batch driver must not be used. pass aperture = MyAperture(POSITIONS, r=5.5) assert aperture._batch_photometry(DATA, error=None, mask=None, method='exact', subpixels=5) is None result = aperture._photometry(DATA) result2 = CircularAperture(POSITIONS, r=5.5)._photometry(DATA) assert_allclose(result.flux, result2.flux, rtol=1e-12, equal_nan=True) def test_decorated_subclass(self): """ Test that aperture subclasses decorated with _enable_batch_photometry opt in to the batch code path. """ @_enable_batch_photometry class MyAperture(CircularAperture): pass aperture = MyAperture(POSITIONS, r=5.5) result = aperture._batch_photometry(DATA, error=None, mask=None, method='exact', subpixels=5) assert result is not None assert_batch_matches_legacy(aperture, DATA, error=ERROR, mask=MASK) def test_base_hook_none(self): """ Test that when a decorated subclass's _batch_shape_params hook returns None, _batch_photometry falls back to None. This covers: - PixelAperture._batch_shape_params (the base ``return`` on its own line, which is the hook's default no-op implementation), and - the ``if spec is None: return None`` branch in _batch_photometry. """ @_enable_batch_photometry class MyAperture(CircularAperture): def _batch_shape_params(self): # Explicitly delegate to the base PixelAperture hook, # which returns None, signalling that the batch driver # is not supported for this subclass. return PixelAperture._batch_shape_params(self) aperture = MyAperture(POSITIONS, r=5.5) assert aperture._batch_photometry(DATA, error=None, mask=None, method='exact', subpixels=5) is None class TestThreadSafety: """ The batch driver must give identical results when called concurrently. """ def test_concurrent_photometry(self): """ Test that the batch driver releases the GIL and is therefore thread-safe by running multiple photometry calls in parallel threads and checking that they all give the same results. """ aperture = CircularAperture(POSITIONS, r=5.5) expected = aperture._photometry(DATA, error=ERROR) def run(_): return aperture._photometry(DATA, error=ERROR) with ThreadPoolExecutor(max_workers=4) as executor: results = list(executor.map(run, range(8))) for result in results: assert_allclose(result.flux, expected.flux, rtol=0, atol=0, equal_nan=True) astropy-photutils-24be529/photutils/aperture/tests/test_batch_photometry_driver.py000066400000000000000000000441441526452531300312020ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the low-level batch Cython aperture photometry driver. """ from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from photutils.aperture import (CircularAnnulus, CircularAperture, EllipticalAnnulus, EllipticalAperture, PolygonAperture, RectangularAnnulus, RectangularAperture) from photutils.aperture._batch_photometry import (FLAG_COL_BBOX_CLIPPED, FLAG_COL_N_PIXELS, SHAPE_CIRCLE, SHAPE_CIRCULAR_ANNULUS, SHAPE_ELLIPSE, SHAPE_ELLIPTICAL_ANNULUS, SHAPE_RECTANGLE, SHAPE_RECTANGULAR_ANNULUS, batch_aperture_sums) from photutils.aperture._common import batch_mask_plane N_THREADS = 8 N_CALLS_PER_THREAD = 4 def _batch_inputs(): """ Build deterministic data, error, mask, and source positions for the batch-driver tests. """ rng = np.random.default_rng(42) data = rng.random((80, 80)) error = rng.random((80, 80)) + 0.1 mask = np.zeros((80, 80), dtype=np.uint8) mask[::7, ::5] = 1 positions = np.array([[20.0, 25.0], [40.0, 40.0], [55.0, 30.0], [10.0, 60.0], [70.0, 70.0], [35.0, 15.0]]) return data, error, mask, positions # Aperture shape specs: (shape_code, params, ext_x, ext_y). # The half-extents need only bound the aperture. They are identical # across the baseline and concurrent calls, so the comparison is exact. _BATCH_SPECS = [ (SHAPE_CIRCLE, [8.0], 8.0, 8.0), (SHAPE_CIRCULAR_ANNULUS, [5.0, 8.0], 8.0, 8.0), (SHAPE_ELLIPSE, [8.0, 5.0, 0.7], 8.0, 8.0), (SHAPE_ELLIPTICAL_ANNULUS, [4.0, 2.0, 8.0, 5.0, 0.7], 8.0, 8.0), (SHAPE_RECTANGLE, [12.0, 7.0, 0.5], 7.0, 7.0), (SHAPE_RECTANGULAR_ANNULUS, [6.0, 4.0, 12.0, 7.0, 0.5], 7.0, 7.0), ] class TestBatchApertureSums: """ Tests for the batch_aperture_sums Cython driver. """ @pytest.mark.parametrize('use_exact', [1, 0]) def test_readonly_arrays(self, use_exact): """ Test that the batch driver accepts read-only (nonwritable) data, error, positions, and params arrays and returns results identical to writable arrays. The data, error, positions, and params arguments are declared as ``const`` typed memoryviews so that read-only arrays do not raise a ``ValueError``. """ data, error, mask, positions = _batch_inputs() params = np.array([8.0], dtype=np.float64) expected = batch_aperture_sums(data, error, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, use_exact, 8) for arr in (data, error, positions, params): arr.setflags(write=False) result = batch_aperture_sums(data, error, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, use_exact, 8) for res_arr, exp_arr in zip(result, expected, strict=True): assert_array_equal(res_arr, exp_arr) @pytest.mark.parametrize(('shape_code', 'params', 'ext_x', 'ext_y'), _BATCH_SPECS) @pytest.mark.parametrize('use_exact', [1, 0]) def test_threadsafe(self, shape_code, params, ext_x, ext_y, use_exact): data, error, mask, positions = _batch_inputs() params = np.array(params, dtype=np.float64) def fn(): return batch_aperture_sums(data, error, mask, positions, shape_code, params, ext_x, ext_y, 0.0, 0.0, use_exact, 8) expected = fn() with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futures = [ex.submit(fn) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] for future in futures: result = future.result() for res_arr, exp_arr in zip(result, expected, strict=True): assert_array_equal(res_arr, exp_arr) def test_mixed_concurrent(self): """ Run every aperture shape through the batch driver concurrently. Mixing shapes within the thread pool surfaces interference between calls if any shared mutable state (e.g., a module-level scratch buffer) were introduced into the ``nogil`` source loop. """ data, error, mask, positions = _batch_inputs() def task(spec): shape_code, params, ext_x, ext_y = spec params = np.array(params, dtype=np.float64) return batch_aperture_sums(data, error, mask, positions, shape_code, params, ext_x, ext_y, 0.0, 0.0, 1, 5) expected = {spec[0]: task(spec) for spec in _BATCH_SPECS} with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futures = {ex.submit(task, spec): spec[0] for spec in _BATCH_SPECS for _ in range(N_THREADS)} for fut, shape_code in futures.items(): result = fut.result() for res_arr, exp_arr in zip(result, expected[shape_code], strict=True): assert_array_equal(res_arr, exp_arr) def test_params_per_source_circle(): rng = np.random.default_rng(0) data = rng.normal(size=(40, 40)) positions = np.array([[10.0, 10.0], [25.0, 25.0], [30.0, 12.0]]) radii = np.array([2.0, 3.5, 5.0]) psrc = radii[:, None].copy() batch = batch_aperture_sums( data, None, None, positions, SHAPE_CIRCLE, None, 0.0, 0.0, 0.0, 0.0, 1, 1, params_per_source=psrc) for i, r in enumerate(radii): single = batch_aperture_sums( data, None, None, positions[i:i + 1], SHAPE_CIRCLE, np.array([r]), r, r, 0.0, 0.0, 1, 1) assert_allclose(batch[0][i], single[0][0], rtol=1e-12) def test_params_per_source_ellipse(): rng = np.random.default_rng(0) data = rng.normal(size=(40, 40)) positions = np.array([[10.0, 10.0], [25.0, 25.0], [30.0, 12.0]]) psrc = np.array([[3.0, 2.0, 0.5], [4.0, 1.0, -0.3], [2.5, 2.5, 1.2]]) batch = batch_aperture_sums( data, None, None, positions, SHAPE_ELLIPSE, None, 0.0, 0.0, 0.0, 0.0, 1, 1, params_per_source=psrc) for i, (semi_a, semi_b, theta) in enumerate(psrc): cos_theta = np.cos(theta) sin_theta = np.sin(theta) ext_x = np.sqrt((semi_a * cos_theta)**2 + (semi_b * sin_theta)**2) ext_y = np.sqrt((semi_a * sin_theta)**2 + (semi_b * cos_theta)**2) single = batch_aperture_sums( data, None, None, positions[i:i + 1], SHAPE_ELLIPSE, np.array([semi_a, semi_b, theta]), ext_x, ext_y, 0.0, 0.0, 1, 1) assert_allclose(batch[0][i], single[0][0], rtol=1e-12) def test_params_per_source_invalid(): data = np.ones((20, 20)) positions = np.array([[10.0, 10.0], [12.0, 12.0]]) psrc = np.array([[2.0], [3.0]]) params = np.array([2.0]) match = 'params must be given when params_per_source is None' with pytest.raises(ValueError, match=match): batch_aperture_sums(data, None, None, positions, SHAPE_CIRCLE, None, 2.0, 2.0, 0.0, 0.0, 1, 1) match = 'give params or params_per_source, not both' with pytest.raises(ValueError, match=match): batch_aperture_sums(data, None, None, positions, SHAPE_CIRCLE, params, 2.0, 2.0, 0.0, 0.0, 1, 1, params_per_source=psrc) match = 'params_per_source supports only circle and ellipse shapes' with pytest.raises(ValueError, match=match): batch_aperture_sums(data, None, None, positions, SHAPE_CIRCULAR_ANNULUS, None, 2.0, 2.0, 0.0, 0.0, 1, 1, params_per_source=psrc) match = 'params_per_source does not support emit_sum' with pytest.raises(ValueError, match=match): batch_aperture_sums(data, None, None, positions, SHAPE_CIRCLE, None, 2.0, 2.0, 0.0, 0.0, 1, 1, None, None, 0, None, 1, params_per_source=psrc) match = 'params_per_source must have one row per position' with pytest.raises(ValueError, match=match): batch_aperture_sums(data, None, None, positions, SHAPE_CIRCLE, None, 2.0, 2.0, 0.0, 0.0, 1, 1, params_per_source=psrc[:1]) match = 'params_per_source has the wrong column count' with pytest.raises(ValueError, match=match): batch_aperture_sums(data, None, None, positions, SHAPE_CIRCLE, None, 2.0, 2.0, 0.0, 0.0, 1, 1, params_per_source=np.zeros((2, 3))) with pytest.raises(ValueError, match=match): batch_aperture_sums(data, None, None, positions, SHAPE_ELLIPSE, None, 2.0, 2.0, 0.0, 0.0, 1, 1, params_per_source=np.zeros((2, 1))) def test_weights_out(): data = np.ones((20, 20)) positions = np.array([[10.0, 10.0], # interior [1.0, 10.0], # aperture off left edge [19.4, 10.0]]) # aperture off right edge params = np.array([3.0]) result = batch_aperture_sums( data, None, None, positions, SHAPE_CIRCLE, params, 3.0, 3.0, 0.0, 0.0, 1, 1) weights_out = result.weights_out assert list(weights_out) == [0, 1, 1] def test_weights_out_clipped_bbox_zero_weight(): """ Test that a bbox row can poke off-image while every off-image pixel has zero aperture fraction. ``weights_out`` must then be 0 even though ``FLAG_COL_BBOX_CLIPPED`` is 1. With method='center' (``use_exact=0``, ``subpixels=1``), a circle of r=2.0 at y=1.4 has iymin = floor(1.4 - 2 + 0.5) = -1 (clipped), but the off-image pixel centers at y=-1 lie 2.4 > r from the center, so their center-method fraction is zero. """ data = np.ones((20, 20)) positions = np.array([[10.0, 1.4]]) params = np.array([2.0]) result = batch_aperture_sums( data, None, None, positions, SHAPE_CIRCLE, params, 2.0, 2.0, 0.0, 0.0, 0, 1) fcounts = result.flag_counts weights_out = result.weights_out assert fcounts[0, FLAG_COL_BBOX_CLIPPED] == 1 assert weights_out[0] == 0 # The same aperture with the exact method has positive area # off-image result = batch_aperture_sums( data, None, None, positions, SHAPE_CIRCLE, params, 2.0, 2.0, 0.0, 0.0, 1, 1) assert result.weights_out[0] == 1 # Aperture factories (taking the positions) whose outside-weight # indicator is checked against the aperture's own mask at edge, corner, # interior, and off-image positions _OUTSIDE_WEIGHT_APERTURES = [ lambda pos: CircularAperture(pos, r=6.0), lambda pos: CircularAnnulus(pos, r_in=3.0, r_out=6.0), lambda pos: EllipticalAperture(pos, a=8.0, b=2.5, theta=0.7), lambda pos: EllipticalAnnulus(pos, a_in=3.0, a_out=8.0, b_out=2.5, theta=0.7), lambda pos: RectangularAperture(pos, w=12.0, h=5.0, theta=0.5), lambda pos: RectangularAnnulus(pos, w_in=5.0, w_out=12.0, h_out=5.0, theta=0.5), # A nonconvex (L-shaped) polygon lambda pos: PolygonAperture(pos, [(-6, -6), (6, -6), (6, -1), (-1, -1), (-1, 6), (-6, 6)]), ] def _outside_weight_positions(): """ Positions on and just beyond every edge and corner of the 40x40 test data, plus interior and far off-image positions. """ rng = np.random.default_rng(7) edge = np.array([-8.0, -3.0, -0.4, 0.3, 2.0, 5.0, 37.0, 39.2, 39.6, 42.0, 47.0]) positions = [(x, 20.0) for x in edge] + [(20.0, y) for y in edge] positions += [(x, y) for x in (-3.0, 1.0, 38.0, 42.0) for y in (-3.0, 1.0, 38.0, 42.0)] positions += [(20.0, 20.0), (12.5, 27.5), (200.0, 200.0), (-100.0, 20.0)] positions += [tuple(rng.uniform(-10, 50, 2)) for _ in range(20)] return np.array(positions, dtype=np.float64) @pytest.mark.parametrize('make_aperture', _OUTSIDE_WEIGHT_APERTURES, ids=lambda fn: type(fn((0, 0))).__name__) @pytest.mark.parametrize(('method', 'use_exact', 'subpixels'), [('exact', 1, 1), ('center', 0, 1), ('subpixel', 0, 3)]) def test_weights_out_matches_mask(make_aperture, method, use_exact, subpixels): """ Test the outside-weight indicator against the aperture mask. The reference is the aperture's own unclipped ``to_mask`` array with the part inside the data zeroed. The indicator must be set exactly when a nonzero mask value remains. """ data = np.ones((40, 40)) positions = _outside_weight_positions() aperture = make_aperture(positions) shape_code, params = aperture._batch_shape_params() ext_x, ext_y = aperture._xy_extents off_x, off_y = aperture._xy_bbox_offset result = batch_aperture_sums( data, None, None, positions, shape_code, np.array(params, dtype=np.float64), float(ext_x), float(ext_y), float(off_x), float(off_y), use_exact, subpixels) expected = np.zeros(len(positions), dtype=np.uint8) clipped = np.zeros(len(positions), dtype=bool) masks = aperture.to_mask(method=method, subpixels=subpixels) for i, mask in enumerate(masks): slc_lg, slc_sm = mask.get_overlap_slices(data.shape) if slc_lg is None: continue bbox = mask.bbox clipped[i] = (bbox.ixmin < 0 or bbox.iymin < 0 or bbox.ixmax > data.shape[1] or bbox.iymax > data.shape[0]) outside = mask.data.copy() outside[slc_sm] = 0.0 expected[i] = np.any(outside > 0) assert_array_equal(result.flag_counts[:, FLAG_COL_BBOX_CLIPPED], clipped) assert_array_equal(result.weights_out, expected) assert np.any(expected[clipped] == 1) def test_weights_out_aperture_entirely_outside(): """ Test an aperture that lies entirely in the clipped-away part of its bounding box. The L-shaped polygon centered at (-3, -3) has its bounding box overlapping the data but no area inside it, so the exact-method ring test (which assumes weight on both sides of the data edge) does not apply and every outside pixel is scanned instead. """ data = np.ones((40, 40)) aperture = PolygonAperture((-3.0, -3.0), [(-6, -6), (6, -6), (6, -1), (-1, -1), (-1, 6), (-6, 6)]) shape_code, params = aperture._batch_shape_params() ext_x, ext_y = aperture._xy_extents off_x, off_y = aperture._xy_bbox_offset for use_exact, subpixels in ((1, 1), (0, 1), (0, 3)): result = batch_aperture_sums( data, None, None, np.array([[-3.0, -3.0]]), shape_code, np.array(params, dtype=np.float64), float(ext_x), float(ext_y), float(off_x), float(off_y), use_exact, subpixels) assert result.overlap[0] assert result.flag_counts[0, FLAG_COL_BBOX_CLIPPED] == 1 assert result.flag_counts[0, FLAG_COL_N_PIXELS] == 0 assert result.weights_out[0] == 1 @pytest.mark.parametrize(('use_exact', 'subpixels'), [(1, 1), (0, 1), (0, 3)]) @pytest.mark.parametrize('radius', [2000.0, 8000.0, 20000.0]) def test_weights_out_large_aperture(radius, use_exact, subpixels): """ Test an aperture whose bounding box is far larger than the data. The outside-weight test is separate from the accumulation loop and stops at the first nonzero-fraction pixel outside the data (for the exact method it tests only the ring of pixels just outside the data edges), so the cost of these apertures does not grow with the (enormous) bounding-box area. """ data = np.ones((200, 200)) positions = np.array([[100.0, 100.0]]) result = batch_aperture_sums( data, None, None, positions, SHAPE_CIRCLE, np.array([radius]), radius, radius, 0.0, 0.0, use_exact, subpixels) assert result.weights_out[0] == 1 assert result.flag_counts[0, FLAG_COL_BBOX_CLIPPED] == 1 # Every data pixel is well inside the aperture, so the sums are # unaffected by the outside-weight scan assert result.flag_counts[0, FLAG_COL_N_PIXELS] == data.size assert_allclose(result.sums[0], data.sum()) assert_allclose(result.areas[0], data.size) class TestBatchMaskPlane: """ Tests for the uint8 mask plane passed to the batch drivers. """ def test_none(self): assert batch_mask_plane(None) is None def test_contiguous_mask_is_view(self): """ Test that a C-contiguous boolean mask is viewed without a copy. """ mask = np.zeros((20, 30), dtype=bool) mask[3, 4] = True plane = batch_mask_plane(mask) assert plane.dtype == np.uint8 assert plane.flags.c_contiguous assert plane.shape == mask.shape assert np.shares_memory(plane, mask) assert plane[3, 4] == 1 assert plane.sum() == 1 def test_noncontiguous_mask_is_copied(self): """ Test that a noncontiguous boolean mask is copied into a C-contiguous plane with the same values. """ mask = np.zeros((40, 60), dtype=bool) mask[6, 8] = True view = mask[::2, ::2] assert not view.flags.c_contiguous plane = batch_mask_plane(view) assert plane.dtype == np.uint8 assert plane.flags.c_contiguous assert not np.shares_memory(plane, mask) assert_array_equal(plane, view.astype(np.uint8)) astropy-photutils-24be529/photutils/aperture/tests/test_batch_sort.py000066400000000000000000000054741526452531300264070ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the callback-free sort of the batch statistics kernels. """ import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.aperture._batch_stats import _heapsort_values, batch_sort_values def _adversarial_arrays(rng, n): """ Arrays of length ``n`` that exercise the partitioning, sentinel, and short-range paths of the introsort. """ base = rng.normal(size=n) arrays = [base, np.sort(base), np.sort(base)[::-1].copy(), np.full(n, 3.5), rng.integers(0, 3, n).astype(float), rng.integers(0, max(n // 4, 1), n).astype(float)] # Organ pipe and sawtooth patterns half = np.arange(n // 2, dtype=float) arrays.append(np.concatenate((half, half[::-1], np.zeros(n % 2)))) arrays.append((np.arange(n) % 7).astype(float)) # Values with the same magnitude and both signs, and extremes arrays.append(np.where(rng.random(n) < 0.5, base, -base)) arrays.append(np.where(rng.random(n) < 0.01, 1e300, base)) return arrays @pytest.mark.parametrize('n', [0, 1, 2, 3, 15, 16, 17, 31, 32, 33, 100, 1000, 100_000]) def test_sort_values_matches_numpy(n): rng = np.random.default_rng(n) for arr in _adversarial_arrays(rng, n): arr = np.ascontiguousarray(arr, dtype=np.float64) starts = np.array([0], dtype=np.intp) counts = np.array([n], dtype=np.intp) result = batch_sort_values(arr, starts, counts) assert_array_equal(result, np.sort(arr)) def test_sort_values_many_sources(): """ Test that each source's slice is sorted independently, including empty sources, and that the input is not modified. """ rng = np.random.default_rng(7) counts = rng.integers(0, 60, 500).astype(np.intp) counts[::13] = 0 counts[1::17] = 1 counts[2::19] = 17 starts = np.concatenate(([0], np.cumsum(counts)[:-1])).astype(np.intp) values = rng.normal(size=int(counts.sum())) original = values.copy() result = batch_sort_values(values, starts, counts) assert_array_equal(values, original) for start, count in zip(starts, counts, strict=True): assert_array_equal(result[start:start + count], np.sort(values[start:start + count])) @pytest.mark.parametrize('n', [0, 1, 2, 3, 16, 17, 100, 1000, 100_000]) def test_heapsort_fallback(n): """ Test the heapsort that the introsort falls back to when its recursion budget is exhausted (not reachable with ordinary inputs). """ rng = np.random.default_rng(n + 1) for arr in _adversarial_arrays(rng, n): values = np.ascontiguousarray(arr, dtype=np.float64).copy() expected = np.sort(values) _heapsort_values(values) assert_array_equal(values, expected) astropy-photutils-24be529/photutils/aperture/tests/test_batch_thread_safety.py000066400000000000000000000444101526452531300302330ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the aperture batch Cython drivers are free-threading compatible and return identical results when run concurrently from multiple threads. This complements the low-level ``batch_aperture_sums`` concurrency tests in ``test_batch_photometry_driver.py`` by covering the ``batch_aperture_gather`` gatherer, the higher-level `ApertureStats` statistics pipeline (which drives the ``_batch_stats`` order/moment/ sigma-clip nogil kernels), and the free-threading opt-in of the compiled modules. """ import importlib import sys import sysconfig import threading from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose, assert_array_equal from photutils.aperture._batch_photometry import (SHAPE_CIRCLE, SHAPE_CIRCULAR_ANNULUS, SHAPE_ELLIPSE, SHAPE_RECTANGLE) from photutils.aperture._batch_stats import batch_aperture_gather from photutils.aperture.circle import CircularAperture from photutils.aperture.stats import ApertureStats from photutils.datasets import make_100gaussians_image N_THREADS = 8 N_CALLS_PER_THREAD = 4 # The compiled aperture modules that carry the # ``# cython: freethreading_compatible=True`` directive. APERTURE_MODULES = ('_batch_photometry', '_batch_stats') GIL_DISABLED = bool(sysconfig.get_config_var('Py_GIL_DISABLED')) def _gather_inputs(): """ Build deterministic data, mask, and source positions for the ``batch_aperture_gather`` tests. """ rng = np.random.default_rng(42) data = rng.random((80, 80)) mask = np.zeros((80, 80), dtype=np.uint8) mask[::7, ::5] = 1 positions = np.array([[20.0, 25.0], [40.0, 40.0], [55.0, 30.0], [10.0, 60.0], [70.0, 70.0], [35.0, 15.0]]) return data, mask, positions # Aperture shape specs for the gatherer: (shape_code, params, ext_x, ext_y). _GATHER_SPECS = [ (SHAPE_CIRCLE, [8.0], 8.0, 8.0), (SHAPE_CIRCULAR_ANNULUS, [5.0, 8.0], 8.0, 8.0), (SHAPE_ELLIPSE, [8.0, 5.0, 0.7], 8.0, 8.0), (SHAPE_RECTANGLE, [12.0, 7.0, 0.5], 7.0, 7.0), ] def _assert_gather_equal(result, expected): """ Assert two ``batch_aperture_gather`` results are equal. The packed ``values``/``local_x``/``local_y`` buffers are allocated with an upper-bound length (the sum of the bounding-box areas), so only the valid per-source region ``[starts[k]:starts[k] + counts[k]]`` is meaningful. The trailing entries are uninitialized and must not be compared. """ values, lx, ly, starts, counts, overlap, fcounts = result (e_values, e_lx, e_ly, e_starts, e_counts, e_overlap, e_fcounts) = expected assert_array_equal(starts, e_starts) assert_array_equal(counts, e_counts) assert_array_equal(overlap, e_overlap) assert_array_equal(fcounts, e_fcounts) for start, count in zip(starts, counts, strict=True): sl = slice(int(start), int(start) + int(count)) assert_array_equal(values[sl], e_values[sl]) assert_array_equal(lx[sl], e_lx[sl]) assert_array_equal(ly[sl], e_ly[sl]) # Properties that exercise the ``_batch_stats`` order-statistic, moment, # MAD, biweight, Gini, and mean/variance nogil kernels. _STATS_PROPERTIES = ('mean', 'median', 'std', 'mad_std', 'biweight_location', 'biweight_midvariance', 'gini', 'min', 'max', 'sum', 'var', 'centroid') class TestFreeThreadingSupport: """ The compiled aperture modules must not re-enable the GIL on a free-threaded build. """ @pytest.mark.parametrize('modname', APERTURE_MODULES) def test_module_importable(self, modname): """ Test that each compiled aperture batch module imports cleanly. On a free-threaded build, importing a module that was not compiled with ``freethreading_compatible=True`` emits a ``RuntimeWarning`` and re-enables the GIL. This import check is the entry point for the free-threading verification below. """ module = importlib.import_module(f'photutils.aperture.{modname}') assert module is not None @pytest.mark.skipif(not GIL_DISABLED, reason='requires a free-threaded (GIL-disabled) build') def test_gil_not_reenabled(self): """ Test that importing the aperture Cython batch modules does not re-enable the GIL on a free-threaded build. Each module is compiled with the ``freethreading_compatible=True`` directive, which marks it as free-threading compatible (``Py_MOD_GIL_NOT_USED``). A module lacking that directive would force the runtime to re-enable the GIL on import, which this test detects. """ for modname in APERTURE_MODULES: importlib.import_module(f'photutils.aperture.{modname}') assert sys._is_gil_enabled() is False class TestBatchApertureGather: """ Tests for the batch_aperture_gather Cython driver. """ def test_accepts_none_local_bkg(self): """ Test that ``batch_aperture_gather`` accepts ``local_bkg=None`` and matches an all-zero local background. The per-pixel background subtraction is guarded by a ``has_bkg`` check so that a `None` ``local_bkg`` is treated as no subtraction rather than dereferencing a NULL memoryview. """ data, mask, positions = _gather_inputs() params = np.array([8.0], dtype=np.float64) zeros = np.zeros(positions.shape[0], dtype=np.float64) none_result = batch_aperture_gather(data, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, None) zero_result = batch_aperture_gather(data, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, zeros) _assert_gather_equal(none_result, zero_result) @pytest.mark.parametrize(('shape_code', 'params', 'ext_x', 'ext_y'), _GATHER_SPECS) def test_threadsafe(self, shape_code, params, ext_x, ext_y): data, mask, positions = _gather_inputs() params = np.array(params, dtype=np.float64) local_bkg = np.linspace(0.0, 0.5, positions.shape[0]) def fn(): return batch_aperture_gather(data, mask, positions, shape_code, params, ext_x, ext_y, 0.0, 0.0, local_bkg) expected = fn() with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futures = [ex.submit(fn) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] for future in futures: _assert_gather_equal(future.result(), expected) def test_mixed_concurrent(self): """ Run every aperture shape through the gatherer concurrently. Mixing shapes within the thread pool surfaces interference between calls if any shared mutable state (e.g., a module-level scratch buffer) were introduced into the ``nogil`` gather loop. """ data, mask, positions = _gather_inputs() local_bkg = np.linspace(0.0, 0.5, positions.shape[0]) def task(spec): shape_code, params, ext_x, ext_y = spec params = np.array(params, dtype=np.float64) return batch_aperture_gather(data, mask, positions, shape_code, params, ext_x, ext_y, 0.0, 0.0, local_bkg) expected = {spec[0]: task(spec) for spec in _GATHER_SPECS} with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futures = {ex.submit(task, spec): spec[0] for spec in _GATHER_SPECS for _ in range(N_THREADS)} for fut, shape_code in futures.items(): _assert_gather_equal(fut.result(), expected[shape_code]) class TestApertureStatsThreadSafety: """ ApertureStats must give identical results when computed concurrently. """ @pytest.mark.parametrize('use_sigma_clip', [False, True]) def test_concurrent_stats(self, use_sigma_clip): """ Test that `ApertureStats` releases the GIL and is thread-safe by running many concurrent calls that drive the ``_batch_stats`` nogil kernels and checking that they all give identical results. """ data = make_100gaussians_image() error = np.sqrt(np.abs(data)) positions = ((145.1, 168.3), (84.7, 224.1), (48.3, 200.3), (200.0, 100.0)) aperture = CircularAperture(positions, r=6.0) sigma_clip = (SigmaClip(sigma=3.0, maxiters=10) if use_sigma_clip else None) def compute(): stats = ApertureStats(data, aperture, error=error, sigma_clip=sigma_clip) return {name: np.asarray(getattr(stats, name)) for name in _STATS_PROPERTIES} expected = compute() with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futures = [ex.submit(compute) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] for future in futures: result = future.result() for name in _STATS_PROPERTIES: assert_allclose(result[name], expected[name], rtol=0, atol=0, equal_nan=True) def test_shared_instance(self): """ Test that a single shared ApertureStats instance can be read concurrently. The instance is documented as immutable after construction, so concurrent readers racing to fill the cached-property caches (which fill without any locking) must all see identical values. """ data = make_100gaussians_image() error = np.sqrt(np.abs(data)) positions = ((145.1, 168.3), (84.7, 224.1), (48.3, 200.3), (200.0, 100.0)) aperture = CircularAperture(positions, r=6.0) stats = ApertureStats(data, aperture, error=error) def read(): return {name: np.asarray(getattr(stats, name)) for name in (*_STATS_PROPERTIES, 'flags')} with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futures = [ex.submit(read) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] results = [future.result() for future in futures] expected = read() for result in results: for name, values in result.items(): assert_allclose(values, expected[name], rtol=0, atol=0, equal_nan=True) @pytest.mark.parametrize('block_max_pixels', [None, 2000]) def test_shared_instance_distinct_properties(self, monkeypatch, block_max_pixels): """ Test that threads reading different properties of one shared ApertureStats instance all see complete results. Each thread starts with a different property, so the threads fill the shared block-reduction cache concurrently, in both the single-block and the multiblock mode. """ if block_max_pixels is not None: monkeypatch.setattr('photutils.aperture.stats._BLOCK_MAX_PIXELS', block_max_pixels) rng = np.random.default_rng(0) data = rng.normal(10.0, 1.0, (300, 300)) aperture = CircularAperture(rng.uniform(20, 280, (200, 2)), r=6.0) names = ('mean', 'std', 'min', 'median', 'mad_std', 'biweight_location', 'gini', 'moments_central', 'centroid', 'flags', 'center_aper_area') sigma_clip = SigmaClip(sigma=3.0, maxiters=10) for _ in range(5): stats = ApertureStats(data, aperture, sigma_clip=sigma_clip) expected = ApertureStats(data, aperture, sigma_clip=sigma_clip) barrier = threading.Barrier(len(names)) def read(first, stats=stats, barrier=barrier): barrier.wait() return np.asarray(getattr(stats, first)) with ThreadPoolExecutor(max_workers=len(names)) as ex: results = list(ex.map(read, names)) for name, values in zip(names, results, strict=True): assert_array_equal(values, np.asarray(getattr(expected, name))) @pytest.mark.parametrize('use_sigma_clip', [False, True]) def test_shared_instance_all_properties(self, use_sigma_clip): """ Regression test that threads reading every property of one shared ApertureStats instance never fail while another thread adds results to the shared block-reduction cache. Each thread reads all of the properties, starting from a different one, so that some threads look up the cache while others fill it. On a free-threaded build this raised "dictionary changed size during iteration". """ rng = np.random.default_rng(0) data = rng.normal(10.0, 1.0, (150, 150)) aperture = CircularAperture(rng.uniform(20, 130, (6, 2)), r=6.0) sigma_clip = SigmaClip(sigma=3.0) if use_sigma_clip else None names = (*_STATS_PROPERTIES, 'moments_central', 'flags', 'center_aper_area') expected_stats = ApertureStats(data, aperture, sigma_clip=sigma_clip) expected = {name: np.asarray(getattr(expected_stats, name)) for name in names} for _ in range(50): stats = ApertureStats(data, aperture, sigma_clip=sigma_clip) barrier = threading.Barrier(N_THREADS) def read(start, stats=stats, barrier=barrier): barrier.wait() order = names[start:] + names[:start] return {name: np.asarray(getattr(stats, name)) for name in order} with ThreadPoolExecutor(max_workers=N_THREADS) as ex: results = list(ex.map(read, range(N_THREADS))) for result in results: for name, values in result.items(): assert_array_equal(values, expected[name]) @pytest.mark.parametrize('use_sigma_clip', [False, True]) def test_shared_instance_index(self, use_sigma_clip): """ Regression test that a shared ApertureStats instance can be indexed and sliced while other threads read its properties. Indexing collects the cached properties that were already calculated from the instance dict. On a free-threaded build this raised "dictionary changed size during iteration" when another thread added a cached property at the same time. """ rng = np.random.default_rng(0) data = rng.normal(10.0, 1.0, (150, 150)) aperture = CircularAperture(rng.uniform(20, 130, (6, 2)), r=6.0) sigma_clip = SigmaClip(sigma=3.0) if use_sigma_clip else None names = (*_STATS_PROPERTIES, 'moments_central', 'flags', 'center_aper_area') expected_stats = ApertureStats(data, aperture, sigma_clip=sigma_clip) expected = {name: np.asarray(getattr(expected_stats, name)) for name in names} for _ in range(50): stats = ApertureStats(data, aperture, sigma_clip=sigma_clip) barrier = threading.Barrier(N_THREADS) def read(start, stats=stats, barrier=barrier): barrier.wait() result = [] for name in names[start:] + names[:start]: _ = getattr(stats, name) result.append((name, np.asarray(getattr(stats[1:4], name)))) return result with ThreadPoolExecutor(max_workers=N_THREADS) as ex: results = list(ex.map(read, range(N_THREADS))) for result in results: for name, values in result: assert_array_equal(values, expected[name][1:4]) def test_no_cross_instance_serialization(self, monkeypatch): """ Test that computing a cached property on one instance does not block the same property on a different instance in another thread. The astropy lazyproperty descriptor holds one lock per class attribute shared by all instances, which serialized the entire computation across instances and threads. The `functools.cached_property` descriptors used now have no lock, so instance B must complete while instance A is still blocked inside its getter. This test deadlocks instance B (until the timeout) if a class-level lock is ever reintroduced. """ import photutils.aperture.stats as stats_mod data = np.ones((50, 50)) stats_a = ApertureStats(data, CircularAperture((25, 25), r=5.0)) stats_b = ApertureStats(data, CircularAperture((30, 30), r=5.0)) entered_a = threading.Event() release_a = threading.Event() real_gather = stats_mod.batch_aperture_gather def blocking_gather(data_arr, mask, positions, *args, **kwargs): # Block only instance A's gather. B's must run freely if positions[0, 0] == 25.0: entered_a.set() assert release_a.wait(timeout=30) return real_gather(data_arr, mask, positions, *args, **kwargs) monkeypatch.setattr(stats_mod, 'batch_aperture_gather', blocking_gather) with ThreadPoolExecutor(max_workers=2) as ex: future_a = ex.submit(lambda: stats_a.median) assert entered_a.wait(timeout=30) # Instance A is now parked inside the median getter chain. # Instance B's median must complete while A is blocked. future_b = ex.submit(lambda: stats_b.median) result_b = future_b.result(timeout=30) assert not future_a.done() release_a.set() result_a = future_a.result(timeout=30) assert_allclose(result_a, result_b) astropy-photutils-24be529/photutils/aperture/tests/test_bounding_box.py000066400000000000000000000175041526452531300267310ustar00rootroot00000000000000# 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 class TestConstruction: """ Tests for creating BoundingBox objects and validating their inputs. """ def test_init(self): bbox = BoundingBox(1, 10, 2, 20) assert bbox.ixmin == 1 assert bbox.ixmax == 10 assert bbox.iymin == 2 assert bbox.iymax == 20 def test_init_minmax(self): 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) @pytest.mark.parametrize('inputs', [([1], [10], [2], [9]), ([1, 2], 10, 2, 9), (1.0, 10.0, 2.0, 9.0), (1.3, 10, 2, 9), (1, 10.3, 2, 9), (1, 10, 2.3, 9), (1, 10, 2, 9.3)]) def test_non_integer_inputs(self, inputs): match = 'ixmin, ixmax, iymin, and iymax must all be integers' with pytest.raises(TypeError, match=match): BoundingBox(*inputs) def test_from_float(self): 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) class TestEqualityAndHashing: """ Tests for BoundingBox comparison and hashing. """ def test_eq(self): 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) def test_eq_other_type(self): """ Test that comparing to a non-BoundingBox returns False instead of raising, so that ``!=`` and container membership work. """ bbox = BoundingBox(1, 10, 2, 20) assert bbox != (1, 10, 2, 20) assert bbox != 3 assert not bbox == (1, 10, 2, 20) # noqa: SIM201 assert bbox not in [(1, 10, 2, 20), 3] assert bbox in [3, BoundingBox(1, 10, 2, 20)] def test_hash(self): """ Test that a BoundingBox is hashable and that equal bounding boxes hash equally. """ bbox = BoundingBox(1, 10, 2, 20) assert hash(bbox) == hash(BoundingBox(1, 10, 2, 20)) assert hash(bbox) != hash(BoundingBox(1, 10, 2, 21)) assert len({bbox, BoundingBox(1, 10, 2, 20)}) == 1 assert {bbox: 'value'}[BoundingBox(1, 10, 2, 20)] == 'value' class TestProperties: """ Tests for the BoundingBox geometric properties and repr. """ def test_repr(self): bbox = BoundingBox(1, 10, 2, 20) expected = 'BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20)' assert repr(bbox) == expected def test_shape(self): bbox = BoundingBox(1, 10, 2, 20) assert bbox.shape == (18, 9) def test_center(self): bbox = BoundingBox(1, 10, 2, 20) assert bbox.center == (10.5, 5) def test_extent(self): bbox = BoundingBox(1, 10, 2, 20) assert_allclose(bbox.extent, (0.5, 9.5, 1.5, 19.5)) class TestOverlapSlices: """ Tests for the BoundingBox get_overlap_slices method. """ def test_get_overlap_slices(self): 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_get_overlap_slices_invalid_shape(self): """ 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,)) class TestSetOperations: """ Tests for the BoundingBox union and intersection methods. """ def test_union(self): 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_intersect(self): 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 def test_intersection_touching(self): """ Test that bounding boxes that share only an edge or a corner do not intersect. """ bbox = BoundingBox(1, 10, 2, 20) assert bbox.intersection(BoundingBox(10, 12, 2, 20)) is None assert bbox.intersection(BoundingBox(1, 10, 20, 25)) is None assert bbox.intersection(BoundingBox(10, 12, 20, 25)) is None assert (bbox & BoundingBox(10, 12, 2, 20)) is None def test_operators_invalid_type(self): """ Test that the | and & operators raise the standard TypeError for a non-BoundingBox operand. """ bbox = BoundingBox(1, 10, 2, 20) match = 'unsupported operand type' with pytest.raises(TypeError, match=match): bbox | (5, 21, 7, 32) with pytest.raises(TypeError, match=match): bbox & (5, 21, 7, 32) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') class TestPlotting: """ Tests for the BoundingBox matplotlib and aperture conversions. """ def test_as_artist(self): 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) def test_plot(self): from matplotlib.patches import Rectangle bbox = BoundingBox(1, 10, 2, 20) patch = bbox.plot() assert isinstance(patch, Rectangle) def test_to_aperture(self): 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 astropy-photutils-24be529/photutils/aperture/tests/test_circle.py000066400000000000000000000300251526452531300255060ustar00rootroot00000000000000# 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 import PolygonAperture, SkyPolygonAperture from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.tests.test_aperture_common import ( BaseTestAnnulusMutation, BaseTestAperture, BaseTestPixelAperture) from photutils.datasets import make_wcs 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(BaseTestPixelAperture): aperture = CircularAperture(POSITIONS, r=3.0) copy_param = 'r' copy_value = 2.0 @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) class TestCircularAnnulus(BaseTestAnnulusMutation, BaseTestPixelAperture): aperture = CircularAnnulus(POSITIONS, r_in=3.0, r_out=7.0) copy_param = 'r_in' copy_value = 2.0 @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 a positive scalar" with pytest.raises(ValueError, match=match): CircularAnnulus(POSITIONS, r_in=3.0, r_out=radius) @staticmethod def test_r_out_not_greater_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): CircularAnnulus(POSITIONS, r_in=7.0, r_out=3.0) with pytest.raises(ValueError, match=match): CircularAnnulus(POSITIONS, r_in=3.0, r_out=3.0) class TestSkyCircularAperture(BaseTestAperture): aperture = SkyCircularAperture(SKYCOORD, r=3.0 * UNIT) copy_param = 'r' copy_value = 2.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) @staticmethod def test_to_pixel_unreachable_position(): """ Test that a position the projection cannot reach is reported by its sky coordinate rather than as a NaN pixel position. """ wcs = make_wcs((100, 100)) center = wcs.pixel_to_world(50, 50) antipode = SkyCoord(center.ra + 180 * u.deg, -center.dec) aperture = SkyCircularAperture(antipode, r=3.0 * UNIT) match = 'The sky coordinate .* has no pixel position under the WCS' with pytest.raises(ValueError, match=match): aperture.to_pixel(wcs) class TestSkyCircularAnnulus(BaseTestAnnulusMutation, BaseTestAperture): aperture = SkyCircularAnnulus(SKYCOORD, r_in=3.0 * UNIT, r_out=7.0 * UNIT) copy_param = 'r_in' copy_value = 2.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 zero" with pytest.raises(ValueError, match=match): SkyCircularAnnulus(SKYCOORD, r_in=3.0 * UNIT, r_out=radius * UNIT) @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 nonangular 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) class TestSlicingAndPositions: """ Tests for slicing circular apertures and validating positions. """ def test_slicing(self): 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_invalid_positions(self): 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) class TestAreaOverlap: """ Tests for the circular aperture area_overlap method. """ def test_area_overlap(self): 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(self): 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) mask = np.zeros(data.shape, dtype=int) match = 'mask must be a boolean array' with pytest.raises(TypeError, match=match): aper.area_overlap(data, mask=mask) class TestToPolygon: """ Tests for converting circular apertures to polygon apertures. """ def test_pixel(self): aper = CircularAperture((10.0, 20.0), r=5.0) poly = aper.to_polygon(n_vertices=200) assert isinstance(poly, PolygonAperture) assert poly.n_vertices == 200 assert poly.is_regular assert abs(poly.area - aper.area) / aper.area < 1e-3 # All vertices lie on the circle of radius r radii = np.hypot(*poly.vertex_offsets.T) assert_allclose(radii, 5.0) def test_pixel_float_n_vertices(self): """ Test that a float n_vertices is coerced to an integer. """ aper = CircularAperture((10.0, 20.0), r=1.0) poly = aper.to_polygon(n_vertices=50.0) assert poly.n_vertices == 50 def test_pixel_multi_position(self): aper = CircularAperture([(10.0, 20.0), (30.0, 40.0)], r=3.0) poly = aper.to_polygon(n_vertices=50) assert poly.vertices.shape == (2, 50, 2) def test_pixel_invalid_n_vertices(self): aper = CircularAperture((0.0, 0.0), r=1.0) match = 'n_vertices must be at least 3' with pytest.raises(ValueError, match=match): aper.to_polygon(n_vertices=2) def test_sky(self): pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyCircularAperture(pos, r=5.0 * u.arcsec) poly = aper.to_polygon(n_vertices=200) assert isinstance(poly, SkyPolygonAperture) assert poly.n_vertices == 200 assert poly.is_regular assert_allclose(poly.outer_radius.to_value(u.arcsec), 5.0) assert poly.vertices.shape == (200,) def test_sky_multi_position(self): pos = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') aper = SkyCircularAperture(pos, r=3.0 * u.arcsec) poly = aper.to_polygon(n_vertices=50) assert poly.vertices.shape == (2, 50) def test_sky_invalid_n_vertices(self): pos = SkyCoord(ra=0.0, dec=0.0, unit='deg') aper = SkyCircularAperture(pos, r=1.0 * u.arcsec) match = 'n_vertices must be at least 3' with pytest.raises(ValueError, match=match): aper.to_polygon(n_vertices=2) def test_sky_wcs_round_trip(self, tan_wcs): """ Test that converting a SkyCircularAperture to a polygon and then to pixels is equivalent to converting to pixels and then to a polygon. """ pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyCircularAperture(pos, r=5.0 * u.arcsec) poly_from_sky = aper.to_polygon(n_vertices=200).to_pixel(tan_wcs) poly_from_pix = aper.to_pixel(tan_wcs).to_polygon(n_vertices=200) assert_allclose(poly_from_sky.area, poly_from_pix.area, rtol=1e-6) assert_allclose(poly_from_sky._xy_extents, poly_from_pix._xy_extents, rtol=1e-6) def test_pixel_to_sky_round_trip(self, tan_wcs): """ Test that converting a CircularAperture to a polygon and then to sky is equivalent to converting to sky and then to a polygon. """ # The bounding-box orientation can differ at the ~1e-5 level because # the sky shape parameters from ``to_sky`` are derived from a local # linear (SVD) approximation, while the polygon ``to_sky`` maps each # vertex exactly, so only rotation-invariant quantities are compared. aper = CircularAperture((50.0, 50.0), r=5.0) sky_from_poly = aper.to_polygon(n_vertices=200).to_sky(tan_wcs) sky_from_aper = aper.to_sky(tan_wcs).to_polygon(n_vertices=200) assert_allclose(sky_from_poly.positions.ra.deg, sky_from_aper.positions.ra.deg) assert_allclose(sky_from_poly.positions.dec.deg, sky_from_aper.positions.dec.deg) assert_allclose(sky_from_poly.perimeter.to_value(u.arcsec), sky_from_aper.perimeter.to_value(u.arcsec), rtol=1e-6) assert_allclose(sky_from_poly.to_pixel(tan_wcs).area, sky_from_aper.to_pixel(tan_wcs).area, rtol=1e-6) astropy-photutils-24be529/photutils/aperture/tests/test_core.py000066400000000000000000000605611526452531300252050ustar00rootroot00000000000000# 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, AperturePhotometry, CircularAperture, EllipticalAnnulus, EllipticalAperture, PolygonAperture, RectangularAnnulus, RectangularAperture, SkyCircularAperture, SkyEllipticalAnnulus, SkyEllipticalAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.core import (_aperture_metadata, _update_method_subpixels_docstring) from photutils.utils.exceptions import PhotutilsDeprecationWarning POSITIONS = [(5, 5), (10, 10), (15, 15)] SCALAR_POS = (5, 5) SCALAR_POS_20 = (20.0, 20.0) TRIANGLE_OFFSETS = [(0.0, 1.0), (-1.0, -1.0), (1.0, -1.0)] SQUARE_OFFSETS = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] # Apertures with a ``theta`` rotation angle, with the size parameters # needed to construct them. The annuli pass their inner axis explicitly # so that no parameter is derived from another at construction, letting # the mutation tests below vary a single parameter in isolation. ROTATABLE_PIXEL_APERTURES = [ (EllipticalAperture, {'a': 6.0, 'b': 2.0}), (EllipticalAnnulus, {'a_in': 3.0, 'a_out': 6.0, 'b_in': 1.5, 'b_out': 4.0}), (RectangularAperture, {'w': 9.0, 'h': 3.0}), (RectangularAnnulus, {'w_in': 4.0, 'w_out': 9.0, 'h_in': 2.0, 'h_out': 6.0}), ] ROTATABLE_SKY_APERTURES = [ (SkyEllipticalAperture, {'a': 6.0 * u.arcsec, 'b': 2.0 * u.arcsec}), (SkyEllipticalAnnulus, {'a_in': 3.0 * u.arcsec, 'a_out': 6.0 * u.arcsec, 'b_out': 4.0 * u.arcsec}), (SkyRectangularAperture, {'w': 9.0 * u.arcsec, 'h': 3.0 * u.arcsec}), (SkyRectangularAnnulus, {'w_in': 4.0 * u.arcsec, 'w_out': 9.0 * u.arcsec, 'h_out': 6.0 * u.arcsec}), ] 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 _CachedMaskAperture(CircularAperture): """ A CircularAperture that caches and reuses its aperture mask, so that any in-place modification of the mask data is observable. """ def to_mask(self, method='exact', subpixels=5): """ Return the cached aperture mask, computing it if needed. """ if '_mask_cache' not in self.__dict__: self.__dict__['_mask_cache'] = super().to_mask( method=method, subpixels=subpixels) return self.__dict__['_mask_cache'] 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 def test_eq_polygons_different_vertex_counts(self): """ Test that __eq__ returns False (rather than propagating the broadcasting ValueError) for polygons with different numbers of vertices. """ aper1 = PolygonAperture(SCALAR_POS, TRIANGLE_OFFSETS) aper2 = PolygonAperture(SCALAR_POS, SQUARE_OFFSETS) assert aper1 != aper2 assert aper2 != aper1 assert not aper1 == aper2 # noqa: SIM201 def test_eq_polygons_different_vertex_counts_containment(self): """ Test that container operations that rely on __eq__ work for polygons with different numbers of vertices. """ aper1 = PolygonAperture(SCALAR_POS, TRIANGLE_OFFSETS) aper2 = PolygonAperture(SCALAR_POS, SQUARE_OFFSETS) apertures = [aper2, aper1] assert aper1 in apertures apertures.remove(aper1) assert apertures == [aper2] def test_eq_different_position_counts(self): """ Test that __eq__ returns False (rather than propagating the broadcasting ValueError) for apertures with different numbers of positions. """ aper1 = CircularAperture(POSITIONS, r=3) aper2 = CircularAperture(POSITIONS[:2], r=3) 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 multiposition aperture (exercises the nonscalar branch). """ aper = CircularAperture(POSITIONS, r=3) bbox = aper.bbox assert isinstance(bbox, list) assert len(bbox) == len(POSITIONS) @pytest.mark.parametrize('subpixels', [True, False]) def test_to_mask_bool_subpixels(self, subpixels): """ Test that a bool subpixels value is rejected. bool is a subclass of int, so True would otherwise be silently used as subpixels=1. """ aper = CircularAperture(SCALAR_POS, r=3) match = 'subpixels must be a strictly positive integer' with pytest.raises(ValueError, match=match): aper.to_mask(method='subpixel', subpixels=subpixels) def test_area_overlap_does_not_modify_mask(self): """ Test that area_overlap does not modify the data of the aperture mask it is given, which it would if a cached mask were reused. """ data = np.ones((25, 25)) mask = np.zeros(data.shape, dtype=bool) mask[12, 12] = True aper = _CachedMaskAperture((12, 12), r=5) expected = aper.to_mask().data.copy() area = aper.area_overlap(data, mask=mask) assert_allclose(aper.to_mask().data, expected) # The masked pixel is still excluded from the area assert_allclose(area, aper.area_overlap(data) - 1.0) class TestPixelAperturePhotometry: """ Tests for error-handling branches of PixelAperture._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_photometry_1d_data_error(self): """ Test that 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._photometry(np.ones(20)) def test_photometry_error_shape_mismatch(self): """ Test that 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._photometry(self.data, error=np.ones((5, 5))) def test_photometry_unit_mismatch_error(self): """ Test that 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._photometry( self.data * u.Jy, error=self.data * u.ct, ) def test_photometry_basic(self): """ Test that photometry returns the expected flux for a uniform data array with no error input, and that the returned error array has the same length as the flux array, filled with NaN. """ result = self.aper._photometry(self.data) assert_allclose(result.flux, np.pi * 9, rtol=1e-3) assert len(result.flux_err) == len(result.flux) assert_allclose(result.flux_err, np.nan) def test_photometry_area_matches_area_overlap(self): """ Test that the result area matches area_overlap for a simple nonmasked, fully-overlapping aperture. """ result = self.aper._photometry(self.data) expected = self.aper.area_overlap(self.data) assert result.area.unit == u.pix**2 assert_allclose(result.area.value, expected) def test_photometry_area_no_overlap_nan(self): """ Test that the result area is NaN for an aperture with no overlap with the data. """ aper = CircularAperture((-50, -50), r=3) result = aper._photometry(self.data) assert np.isnan(result.area.value).all() def test_photometry_area_units(self): """ Test that the result area always has units of pix**2 regardless of whether the data/error are Quantity or plain arrays. """ result = self.aper._photometry(self.data) assert result.area.unit == u.pix**2 result_q = self.aper._photometry(self.data * u.Jy, error=self.data * u.Jy) assert result_q.area.unit == u.pix**2 assert_allclose(result.area.value, result_q.area.value, equal_nan=True) def test_photometry_area_batch_vs_mask_path(self): """ Test that the batch and mask code paths produce identical area values. """ aper = CircularAperture(POSITIONS, r=5.5) mask = np.zeros(self.data.shape, dtype=bool) mask[5, 5] = True batch = aper._batch_photometry(self.data, error=None, mask=mask, method='exact', subpixels=5) assert batch is not None legacy = aper._mask_photometry(self.data, error=None, mask=mask, method='exact', subpixels=5) # Areas are the third element of each result assert_allclose(batch[2], legacy[2], rtol=1e-12, equal_nan=True) def test_do_photometry_deprecated(self): """ Test that the deprecated do_photometry method emits a deprecation warning and returns a plain (flux, flux_err) 2-tuple matching the private photometry engine. """ error = np.full(self.data.shape, 0.1) expected = self.aper._photometry(self.data, error=error) match = r'Use AperturePhotometry instead' with pytest.warns(PhotutilsDeprecationWarning, match=match): result = self.aper.do_photometry(self.data, error=error) assert len(result) == 2 flux, flux_err = result assert_allclose(flux, expected.flux) assert_allclose(flux_err, expected.flux_err) class TestApertureParameterMutation: """ Tests that reassigning an aperture parameter after construction gives results identical to a freshly constructed aperture. """ @staticmethod def _assert_same_geometry(aper, expected): assert_allclose(aper._xy_extents, expected._xy_extents) assert aper.bbox == expected.bbox assert_allclose(aper.to_mask().data, expected.to_mask().data) assert_allclose(aper.area, expected.area) data = np.arange(41 * 41, dtype=float).reshape((41, 41)) assert_allclose(AperturePhotometry(data, aper).flux, AperturePhotometry(data, expected).flux) @pytest.mark.parametrize(('aperture_class', 'params'), ROTATABLE_PIXEL_APERTURES) def test_theta_reassignment(self, aperture_class, params): """ Test that reassigning theta invalidates all of the cached geometry, including the rotation angle in radians. """ theta = 0.7 aper = aperture_class(SCALAR_POS_20, theta=0.0, **params) # Cache the cached properties derived from the original theta aper.to_mask() aper.theta = theta expected = aperture_class(SCALAR_POS_20, theta=theta, **params) assert aper._theta_rad == expected._theta_rad self._assert_same_geometry(aper, expected) @pytest.mark.parametrize(('aperture_class', 'params'), ROTATABLE_PIXEL_APERTURES) def test_size_reassignment(self, aperture_class, params): """ Test that reassigning a size parameter invalidates all of the cached geometry. """ name, value = next(iter(params.items())) aper = aperture_class(SCALAR_POS_20, theta=0.3, **params) aper.to_mask() setattr(aper, name, value * 0.5) expected = aperture_class(SCALAR_POS_20, theta=0.3, **{**params, name: value * 0.5}) self._assert_same_geometry(aper, expected) @pytest.mark.parametrize(('aperture_class', 'params'), ROTATABLE_PIXEL_APERTURES) def test_positions_reassignment(self, aperture_class, params): """ Test that reassigning positions invalidates all of the cached geometry. """ aper = aperture_class(SCALAR_POS_20, theta=0.3, **params) aper.to_mask() aper.positions = (23.0, 17.0) expected = aperture_class((23.0, 17.0), theta=0.3, **params) self._assert_same_geometry(aper, expected) @pytest.mark.parametrize(('aperture_class', 'params'), ROTATABLE_SKY_APERTURES) def test_sky_theta_reassignment(self, aperture_class, params, tan_wcs): """ Test that reassigning theta on a sky aperture invalidates the cached rotation angle in radians. """ theta = 35.0 * u.deg position = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = aperture_class(position, theta=0.0 * u.deg, **params) # Cache the cached properties derived from the original theta aper.to_pixel(tan_wcs) aper.theta = theta expected = aperture_class(position, theta=theta, **params) assert aper._theta_rad == expected._theta_rad assert aper.to_pixel(tan_wcs) == expected.to_pixel(tan_wcs) 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 multiposition 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 multiposition 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 multiposition 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 class TestMethodSubpixelsDocstring: """ Tests for the _update_method_subpixels_docstring decorator. """ def test_insert_descriptions(self): """ Test that the placeholder is replaced by the method and subpixels descriptions. """ @_update_method_subpixels_docstring def func(): """ Summary. Parameters ---------- """ doc = func.__doc__ assert '' not in doc assert "method : {'exact', 'center', 'subpixel'}, optional" in doc assert 'subpixels : int, optional' in doc def test_indentation_preserved(self): """ Test that the inserted text is indented to match the placeholder. The docstring is assigned manually to avoid the compile-time docstring dedenting introduced in Python 3.13. """ def func(): pass func.__doc__ = ('Summary.\n\n' ' Parameters\n' ' ----------\n' ' \n') _update_method_subpixels_docstring(func) lines = func.__doc__.splitlines() method_line = next(line for line in lines if line.lstrip().startswith('method :')) assert method_line.startswith(' method :') subpixels_line = next(line for line in lines if line.lstrip().startswith('subpixels :')) assert subpixels_line.startswith(' subpixels :') def test_no_placeholder_noop(self): """ Test that the decorator is a no-op if no placeholder is present. The docstring is assigned manually to avoid the compile-time docstring dedenting introduced in Python 3.13. """ def func(): pass docstring = '\n Summary with no placeholder.\n ' func.__doc__ = docstring assert _update_method_subpixels_docstring(func) is func assert func.__doc__ == docstring def test_none_docstring_noop(self): """ Test that the decorator is a no-op if no docstring exists. """ @_update_method_subpixels_docstring def func(): pass assert func.__doc__ is None def test_class_docstring(self): """ Test that the decorator also updates a class docstring. """ @_update_method_subpixels_docstring class Example: """ Summary. Parameters ---------- """ assert '' not in Example.__doc__ assert 'subpixels : int, optional' in Example.__doc__ def test_method_bullets_placeholder(self): """ Test that the method-bullets placeholder is replaced by only the method bullet list, without the method header or the subpixels description. """ @_update_method_subpixels_docstring def func(): """ Summary. Parameters ---------- """ doc = func.__doc__ assert '' not in doc assert "* ``'exact'`` (default):" in doc assert "* ``'center'``:" in doc assert "* ``'subpixel'``:" in doc assert "method : {'exact', 'center', 'subpixel'}, optional" not in doc assert 'subpixels : int, optional' not in doc def test_subpixels_description_placeholder(self): """ Test that the subpixels-description placeholder is replaced by only the subpixels parameter description. """ @_update_method_subpixels_docstring def func(): """ Summary. Parameters ---------- """ doc = func.__doc__ assert '' not in doc assert 'subpixels : int, optional' in doc assert "method : {'exact', 'center', 'subpixel'}, optional" not in doc assert "* ``'exact'`` (default):" not in doc astropy-photutils-24be529/photutils/aperture/tests/test_ellipse.py000066400000000000000000000343521526452531300257110ustar00rootroot00000000000000# 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 numpy.testing import assert_allclose from photutils.aperture import (PolygonAperture, SkyPolygonAperture, aperture_photometry) from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.tests.test_aperture_common import ( BaseTestAnnulusMutation, BaseTestAperture, BaseTestRotatedPixelAperture) 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(BaseTestRotatedPixelAperture): aperture = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=np.pi / 2.0) copy_param = 'a' copy_value = 20.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) @pytest.mark.parametrize(('position', 'a', 'b', 'theta'), [ ((10.5, 10), 1, 0.5, 0), ((10.5, 10), 2, 0.5, 0), ((10, 10.5), 0.5, 0.5, 0), ((10, 10), 0.5, 0.5, np.pi / 4), ((10.5, 10.5), 1, 1, np.pi / 2), ((10, 10), 1.5, 0.5, np.pi / 2), ((10.5, 10.5), 3, 2, 0), ((10.3, 10.1), 1, 0.5, 0.3), ]) def test_exact_mask_degenerate(position, a, b, theta): """ Test the exact mask when pixel edges are tangent to the ellipse or pixel corners lie exactly on it. """ aper = EllipticalAperture(position, a, b, theta=theta) mask = aper.to_mask(method='exact').data assert_allclose(mask.sum(), np.pi * a * b, rtol=1e-10) assert mask.min() >= 0.0 assert mask.max() <= 1.0 + 1e-12 data = np.ones((21, 21)) phot = aperture_photometry(data, aper, method='exact') assert_allclose(phot['aperture_sum'][0], np.pi * a * b, rtol=1e-10) def test_exact_mask_degenerate_annulus(): """ Test the exact annulus mask when pixel edges are tangent to both ellipses. """ aper = EllipticalAnnulus((10.5, 10), 1, 2, 1, theta=0) mask = aper.to_mask(method='exact').data assert_allclose(mask.sum(), 1.5 * np.pi, rtol=1e-10) assert mask.min() >= 0.0 assert mask.max() <= 1.0 + 1e-12 class TestEllipticalAnnulus(BaseTestAnnulusMutation, BaseTestRotatedPixelAperture): aperture = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=np.pi / 3) copy_param = 'a_in' copy_value = 2.0 @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 a positive scalar" 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_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) def test_a_in_greater_than_a_out(self): """ Test that a ValueError is raised when a_in >= a_out. """ match = "'a_out' must be greater than 'a_in'" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=8.0, b_out=5.0, theta=np.pi / 3) class TestSkyEllipticalAperture(BaseTestAperture): aperture = SkyEllipticalAperture(SKYCOORD, a=10.0 * UNIT, b=5.0 * UNIT, theta=30 * u.deg) copy_param = 'a' copy_value = 2.0 * UNIT @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) class TestSkyEllipticalAnnulus(BaseTestAnnulusMutation, BaseTestAperture): aperture = SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) copy_param = 'a_in' copy_value = 2.0 * UNIT @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 zero" 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_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_a_in_greater_than_a_out(self): """ Test that a ValueError is raised when a_in >= a_out. """ match = "'a_out' must be greater than 'a_in'" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=8.0 * UNIT, b_out=5.0 * UNIT, theta=60 * u.deg) class TestThetaQuantity: """ Tests for a theta input as an angular Quantity. """ def test_aperture(self): 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_annulus(self): 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) class TestToPolygon: """ Tests for converting elliptical apertures to polygon apertures. """ def test_pixel(self): aper = EllipticalAperture((10.0, 20.0), a=5.0, b=3.0, theta=np.pi / 4) poly = aper.to_polygon(n_vertices=200) assert isinstance(poly, PolygonAperture) assert poly.n_vertices == 200 assert abs(poly.area - aper.area) / aper.area < 1e-3 def test_pixel_multi_position(self): aper = EllipticalAperture([(10.0, 20.0), (30.0, 40.0)], a=4.0, b=2.0, theta=0.5) poly = aper.to_polygon(n_vertices=50) assert poly.vertices.shape == (2, 50, 2) def test_pixel_invalid_n_vertices(self): aper = EllipticalAperture((0.0, 0.0), a=1.0, b=1.0) match = 'n_vertices must be at least 3' with pytest.raises(ValueError, match=match): aper.to_polygon(n_vertices=2) def test_sky(self): pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyEllipticalAperture(pos, a=5.0 * u.arcsec, b=3.0 * u.arcsec, theta=30 * u.deg) poly = aper.to_polygon(n_vertices=200) assert isinstance(poly, SkyPolygonAperture) assert poly.n_vertices == 200 assert poly.vertices.shape == (200,) # The maximum and minimum vertex distances equal the semimajor and # semiminor axes, respectively. radii = np.hypot(*poly.vertex_offsets.to_value(u.arcsec).T) assert_allclose(radii.max(), 5.0) assert_allclose(radii.min(), 3.0) def test_sky_multi_position(self): pos = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') aper = SkyEllipticalAperture(pos, a=4.0 * u.arcsec, b=2.0 * u.arcsec, theta=0.5 * u.rad) poly = aper.to_polygon(n_vertices=50) assert poly.vertices.shape == (2, 50) def test_sky_invalid_n_vertices(self): pos = SkyCoord(ra=0.0, dec=0.0, unit='deg') aper = SkyEllipticalAperture(pos, a=1.0 * u.arcsec, b=1.0 * u.arcsec) match = 'n_vertices must be at least 3' with pytest.raises(ValueError, match=match): aper.to_polygon(n_vertices=2) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_sky_wcs_round_trip(self, theta_deg, tan_wcs): """ Test that converting a SkyEllipticalAperture to a polygon and then to pixels is equivalent to converting to pixels and then to a polygon. """ pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyEllipticalAperture(pos, a=5.0 * u.arcsec, b=3.0 * u.arcsec, theta=theta_deg * u.deg) poly_from_sky = aper.to_polygon(n_vertices=200).to_pixel(tan_wcs) poly_from_pix = aper.to_pixel(tan_wcs).to_polygon(n_vertices=200) assert_allclose(poly_from_sky.area, poly_from_pix.area, rtol=1e-6) assert_allclose(poly_from_sky._xy_extents, poly_from_pix._xy_extents, rtol=1e-6) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_sky_polygon_orientation(self, theta_deg, tan_wcs): """ Test that the sky polygon covers the same pixels as the sky ellipse, which requires the same rotation sense. """ pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyEllipticalAperture(pos, a=50.0 * u.arcsec, b=20.0 * u.arcsec, theta=theta_deg * u.deg) shape = (101, 101) mask_aper = aper.to_pixel(tan_wcs).to_mask(method='exact') mask_poly = aper.to_polygon(n_vertices=400).to_pixel( tan_wcs).to_mask(method='exact') assert_allclose(mask_poly.to_image(shape), mask_aper.to_image(shape), atol=2e-3) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_pixel_to_sky_round_trip(self, theta_deg, tan_wcs): """ Test that converting an EllipticalAperture to a polygon and then to sky is equivalent to converting to sky and then to a polygon. """ # The bounding-box orientation can differ at the ~1e-5 level because # the sky shape parameters from ``to_sky`` are derived from a local # linear (SVD) approximation, while the polygon ``to_sky`` maps # each vertex exactly, so only rotation-invariant quantities are # compared. aper = EllipticalAperture((50.0, 50.0), a=5.0, b=3.0, theta=np.deg2rad(theta_deg)) sky_from_poly = aper.to_polygon(n_vertices=200).to_sky(tan_wcs) sky_from_aper = aper.to_sky(tan_wcs).to_polygon(n_vertices=200) assert_allclose(sky_from_poly.positions.ra.deg, sky_from_aper.positions.ra.deg) assert_allclose(sky_from_poly.positions.dec.deg, sky_from_aper.positions.dec.deg) assert_allclose(sky_from_poly.perimeter.to_value(u.arcsec), sky_from_aper.perimeter.to_value(u.arcsec), rtol=1e-6) assert_allclose(sky_from_poly.to_pixel(tan_wcs).area, sky_from_aper.to_pixel(tan_wcs).area, rtol=1e-6) astropy-photutils-24be529/photutils/aperture/tests/test_exact_area_photometry.py000066400000000000000000000171241526452531300306400ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Validation tests for the ``method='exact'`` mask mode of pixel apertures. When the data array is all ones, the photometric sum returned by an aperture in exact mode must equal the analytic geometric area of the aperture shape (up to floating-point error). These tests verify this invariant for circular, elliptical, rectangular, and polygon apertures (including their annulus counterparts), across a variety of shapes, rotation angles, subpixel positions, and convex/nonconvex polygons. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.aperture import (CircularAnnulus, CircularAperture, EllipticalAnnulus, EllipticalAperture, PolygonAperture, RectangularAnnulus, RectangularAperture, aperture_photometry) def _phot_sum(aperture, *, data=None): if data is None: shape = (101, 101) data = np.ones(shape, dtype=float) table = aperture_photometry(data, aperture, method='exact') return float(table['aperture_sum'][0]) # Polygon shape library of (name, vertex_offsets) pairs. Each polygon # is centered at the origin so it can be placed at arbitrary positions. def _triangle(): # Equilateral triangle with circumradius 4. angles = np.pi / 2 + np.arange(3) * 2 * np.pi / 3 return np.column_stack([4.0 * np.cos(angles), 4.0 * np.sin(angles)]) def _hexagon(): angles = np.arange(6) * np.pi / 3 return np.column_stack([5.0 * np.cos(angles), 5.0 * np.sin(angles)]) def _l_shape(): # Nonconvex L-shape (centered). return np.array([[-2.0, -2.0], [2.0, -2.0], [2.0, 0.0], [0.0, 0.0], [0.0, 2.0], [-2.0, 2.0]]) def _star_5(): # Nonconvex 5-pointed star. verts = [] for i in range(10): r = 5.0 if i % 2 == 0 else 2.0 a = np.pi / 2 + i * np.pi / 5 verts.append((r * np.cos(a), r * np.sin(a))) return np.array(verts) def _arrow(): # Nonconvex arrow shape. return np.array([[-3.0, -1.0], [1.0, -1.0], [1.0, -2.0], [3.0, 0.0], [1.0, 2.0], [1.0, 1.0], [-3.0, 1.0]]) def _rotate(verts, theta_deg): c = np.cos(np.deg2rad(theta_deg)) s = np.sin(np.deg2rad(theta_deg)) rot = np.array([[c, -s], [s, c]]) return verts @ rot.T POLY_SHAPES = { 'triangle': _triangle(), 'hexagon': _hexagon(), 'l_shape': _l_shape(), 'star_5': _star_5(), 'arrow': _arrow(), } class TestBuiltinShapeAreas: """ The exact-method aperture sum over a uniform image must equal the analytic aperture area. """ @pytest.mark.parametrize('center', [(50.0, 50.0), (50.3, 50.7), (49.5, 50.5), (12.123, 87.987)]) @pytest.mark.parametrize('r', [1.0, 3.5, 7.25]) def test_circular_aperture(self, center, r): aper = CircularAperture(center, r=r) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-6, atol=1e-10) @pytest.mark.parametrize('center', [(50.0, 50.0), (50.4, 50.6)]) @pytest.mark.parametrize(('r_in', 'r_out'), [(1.0, 3.0), (2.5, 4.75), (5.0, 8.0)]) def test_circular_annulus(self, center, r_in, r_out): aper = CircularAnnulus(center, r_in=r_in, r_out=r_out) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-6, atol=1e-10) @pytest.mark.parametrize('center', [(50.0, 50.0), (50.3, 49.8)]) @pytest.mark.parametrize(('a', 'b'), [(5.0, 3.0), (3.5, 1.25), (8.0, 8.0)]) @pytest.mark.parametrize('theta_deg', [0.0, 17.5, 45.0, 90.0, 137.0]) def test_elliptical_aperture(self, center, a, b, theta_deg): aper = EllipticalAperture(center, a=a, b=b, theta=np.deg2rad(theta_deg)) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-6, atol=1e-10) @pytest.mark.parametrize(('a_in', 'a_out', 'b_out'), [(2.0, 5.0, 3.0), (1.0, 4.0, 4.0)]) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 65.0]) def test_elliptical_annulus(self, a_in, a_out, b_out, theta_deg): aper = EllipticalAnnulus((50.0, 50.0), a_in=a_in, a_out=a_out, b_out=b_out, theta=np.deg2rad(theta_deg)) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-6, atol=1e-10) @pytest.mark.parametrize('center', [(50.0, 50.0), (50.5, 50.5), (50.123, 49.876)]) @pytest.mark.parametrize(('w', 'h'), [(4.0, 2.0), (5.5, 5.5), (10.0, 1.0)]) @pytest.mark.parametrize('theta_deg', [0.0, 22.5, 45.0, 90.0, 117.0]) def test_rectangular_aperture(self, center, w, h, theta_deg): aper = RectangularAperture(center, w=w, h=h, theta=np.deg2rad(theta_deg)) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-12, atol=1e-12) @pytest.mark.parametrize(('w_in', 'w_out', 'h_out'), [(2.0, 6.0, 4.0), (1.5, 5.5, 5.5)]) @pytest.mark.parametrize('theta_deg', [0.0, 33.0, 90.0]) def test_rectangular_annulus(self, w_in, w_out, h_out, theta_deg): aper = RectangularAnnulus((50.0, 50.0), w_in=w_in, w_out=w_out, h_out=h_out, theta=np.deg2rad(theta_deg)) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-12, atol=1e-12) class TestSubPixelApertureAreas: """ Apertures smaller than a pixel, centered exactly on a pixel center, must still return the analytic area in exact mode. """ def test_circular_aperture(self): aper = CircularAperture((50.0, 50.0), r=0.3) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-10) @pytest.mark.parametrize('theta_deg', [0.0, 30.0]) def test_elliptical_aperture(self, theta_deg): aper = EllipticalAperture((50.0, 50.0), a=0.4, b=0.3, theta=np.deg2rad(theta_deg)) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-10) @pytest.mark.parametrize('theta_deg', [0.0, 30.0]) def test_rectangular_aperture(self, theta_deg): aper = RectangularAperture((50.0, 50.0), w=0.6, h=0.8, theta=np.deg2rad(theta_deg)) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-12) class TestPolygonAreas: """ The exact-method polygon aperture sum must equal the analytic polygon area, including for nonconvex shapes. """ @pytest.mark.parametrize('shape_name', list(POLY_SHAPES)) @pytest.mark.parametrize('center', [(50.0, 50.0), (50.5, 50.5), (50.123, 49.876)]) @pytest.mark.parametrize('theta_deg', [0.0, 23.5, 60.0]) def test_polygon_aperture(self, shape_name, center, theta_deg): offsets = _rotate(POLY_SHAPES[shape_name], theta_deg) aper = PolygonAperture(center, offsets) assert_allclose(_phot_sum(aper), aper.area, rtol=1e-12, atol=1e-12) def test_polygon_multiple_positions(self): """ Test that the exact area is returned for multiple positions. The photometric sum should equal the area regardless of the position. """ offsets = _hexagon() positions = [(30.0, 40.0), (50.5, 50.5), (70.123, 60.876)] aper = PolygonAperture(positions, offsets) shape = (101, 101) data = np.ones(shape, dtype=float) table = aperture_photometry(data, aper, method='exact') expected = aper.area assert_allclose(table['aperture_sum'], expected, rtol=1e-12, atol=1e-12) astropy-photutils-24be529/photutils/aperture/tests/test_flags.py000066400000000000000000000202261526452531300253430ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the flags module. """ import numpy as np import pytest from photutils.aperture.flags import (APERTURE_FLAGS, _ApertureFlags, decode_aperture_flags) from photutils.utils._flags import FlagDefinition, define_flag_docstring EXPECTED_FLAGS = { 'no_overlap': 1, 'partial_overlap': 2, 'no_pixels': 4, 'masked_pixels': 8, 'non_finite_data': 16, 'non_finite_error': 32, 'neighbor_pixels': 64, 'uncorrected_pixels': 128, 'all_masked': 256, 'sigma_clipped': 512, 'all_clipped': 1024, 'too_few_pixels': 2048, 'undefined_shape': 4096, 'singular_covariance': 8192, 'centroid_outside_bbox': 16384, } class TestDecodeApertureFlags: """ Tests for the decode_aperture_flags function. """ def test_decode(self): """ Test the decode_aperture_flags standalone function. """ decoded = decode_aperture_flags(0) assert decoded == [] assert isinstance(decoded, list) # Test each single flag value for name, bit_value in EXPECTED_FLAGS.items(): assert decode_aperture_flags(bit_value) == [name] # Test combination of flags decoded = decode_aperture_flags(10) # bits 2 and 8 assert decoded == ['partial_overlap', 'masked_pixels'] decoded = decode_aperture_flags(5) # bits 1 and 4 assert decoded == ['no_overlap', 'no_pixels'] # Test with all flags set all_flags = sum(EXPECTED_FLAGS.values()) decoded = decode_aperture_flags(all_flags) assert decoded == list(EXPECTED_FLAGS) # Test with array input flags_array = [0, 1, 2, 10] decoded_list = decode_aperture_flags(flags_array) assert len(decoded_list) == 4 assert decoded_list[0] == [] assert decoded_list[1] == ['no_overlap'] assert decoded_list[2] == ['partial_overlap'] assert decoded_list[3] == ['partial_overlap', 'masked_pixels'] # Test with numpy array decoded_list = decode_aperture_flags(np.array([8, 16, 256])) assert decoded_list == [['masked_pixels'], ['non_finite_data'], ['all_masked']] # Test with 0D numpy array (scalar array) decoded = decode_aperture_flags(np.array(32)) assert decoded == ['non_finite_error'] # Test with empty array decoded = decode_aperture_flags(np.array([], dtype=int)) assert decoded == [] # Test with 2D array (shape preserved as nested lists) decoded = decode_aperture_flags(np.array([[0, 1], [8, 264]])) assert len(decoded) == 2 assert decoded == [[[], ['no_overlap']], [['masked_pixels'], ['masked_pixels', 'all_masked']]] def test_return_bit_values(self): """ Test decode_aperture_flags with return_bit_values=True. """ assert decode_aperture_flags(0, return_bit_values=True) == [] assert decode_aperture_flags(10, return_bit_values=True) == [2, 8] decoded_list = decode_aperture_flags([1, 24], return_bit_values=True) assert decoded_list == [[1], [8, 16]] def test_errors(self): """ Test decode_aperture_flags error conditions. """ match = 'Flag value must be an integer' with pytest.raises(TypeError, match=match): decode_aperture_flags(3.14) with pytest.raises(TypeError, match=match): decode_aperture_flags('invalid') with pytest.raises(TypeError, match=match): decode_aperture_flags([1, 2.5, 3]) match = 'Flag value must be a nonnegative integer' with pytest.raises(ValueError, match=match): decode_aperture_flags(-2) def test_docstring(self): """ Test that decode_aperture_flags has dynamic flag documentation. """ docstring = decode_aperture_flags.__doc__ assert '' not in docstring for name, bit_value in EXPECTED_FLAGS.items(): expected = f"**{bit_value}** (``'{name}'``)" assert expected in docstring class TestApertureFlagsRegistry: """ Tests for the APERTURE_FLAGS registry singleton. """ def test_singleton(self): """ Test APERTURE_FLAGS singleton behavior. """ assert isinstance(APERTURE_FLAGS, _ApertureFlags) assert APERTURE_FLAGS.domain == 'aperture' new_flags = _ApertureFlags() assert isinstance(new_flags, _ApertureFlags) assert new_flags is not APERTURE_FLAGS def test_constants(self): """ Test _ApertureFlags constant access. """ for name, bit_value in EXPECTED_FLAGS.items(): const_name = name.upper() assert hasattr(APERTURE_FLAGS, const_name) actual_value = getattr(APERTURE_FLAGS, const_name) assert actual_value == bit_value assert isinstance(actual_value, int) match = "has no attribute 'INVALID'" with pytest.raises(AttributeError, match=match): _ = APERTURE_FLAGS.INVALID def test_properties(self): """ Test _ApertureFlags property access methods. """ assert APERTURE_FLAGS.bit_values == list(EXPECTED_FLAGS.values()) assert APERTURE_FLAGS.names == list(EXPECTED_FLAGS) assert APERTURE_FLAGS.flag_dict == {bit: name for name, bit in EXPECTED_FLAGS.items()} all_flags = APERTURE_FLAGS.all_flags assert isinstance(all_flags, list) assert len(all_flags) == len(EXPECTED_FLAGS) for flag_def in all_flags: assert isinstance(flag_def, FlagDefinition) def test_get_methods(self): """ Test _ApertureFlags getter methods. """ for name, bit_value in EXPECTED_FLAGS.items(): assert APERTURE_FLAGS.get_name(bit_value) == name assert APERTURE_FLAGS.get_bit_value(name) == bit_value assert isinstance(APERTURE_FLAGS.get_description(bit_value), str) detailed = APERTURE_FLAGS.get_detailed_description(bit_value) assert isinstance(detailed, str) # Test get_definition by bit value and name def_by_bit = APERTURE_FLAGS.get_definition(1) def_by_name = APERTURE_FLAGS.get_definition('no_overlap') assert def_by_bit is def_by_name assert def_by_bit.name == 'no_overlap' # NumPy integers (e.g., values from a flags table column) are # accepted as bit values assert APERTURE_FLAGS.get_name(np.int64(8)) == 'masked_pixels' assert APERTURE_FLAGS.get_definition(np.int32(1)) is def_by_bit # Test error cases match = 'No flag with bit value 999' with pytest.raises(KeyError, match=match): APERTURE_FLAGS.get_definition(999) match = "No flag with name 'invalid'" with pytest.raises(KeyError, match=match): APERTURE_FLAGS.get_definition('invalid') match = 'identifier must be int' with pytest.raises(TypeError, match=match): APERTURE_FLAGS.get_definition(3.14) def test_completeness(self): """ Test that _ApertureFlags bit values and names are consistent. """ bit_values = APERTURE_FLAGS.bit_values names = APERTURE_FLAGS.names # Bit values are unique powers of 2 assert len(bit_values) == len(set(bit_values)) for bit_val in bit_values: assert bit_val > 0 assert (bit_val & (bit_val - 1)) == 0 # Names are unique, valid snake_case identifiers assert len(names) == len(set(names)) for name in names: assert name.isidentifier() assert name == name.lower() def test_define_docstring_invalid_registry(self): """ Test that define_flag_docstring rejects a non-FlagRegistry input. """ match = 'registry must be an instance of FlagRegistry' with pytest.raises(TypeError, match=match): define_flag_docstring('invalid') astropy-photutils-24be529/photutils/aperture/tests/test_mask.py000066400000000000000000000253701526452531300252070ustar00rootroot00000000000000# 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.ellipse import EllipticalAnnulus from photutils.aperture.mask import ApertureMask from photutils.aperture.rectangle import RectangularAnnulus # Positions with no or partial overlap with a 50x50 array POSITIONS = [(-20, -20), (-20, 20), (20, -20), (60, 60)] class TestApertureMaskConstruction: """ Tests for creating ApertureMask objects and converting them to arrays. """ def test_input_shapes(self): 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_array(self): 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_copy(self): 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 class TestCutout: """ Tests for the ApertureMask cutout, to_image, and overlap-slice methods. """ def test_get_overlap_slices(self): 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_cutout_shape(self): 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_cutout_copy(self): 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_cutout_no_overlap(self, 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_cutout_partial_overlap(self, 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_cutout_partial_overlap_quantity(self): """ 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 @pytest.mark.parametrize('value', [np.nan, np.inf]) def test_nonfinite_fill_value(self, 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]) class TestMultiply: """ Tests for the ApertureMask multiply method. """ def test_multiply(self): 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_multiply_quantity(self): 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 def test_multiply_fill_value(self): 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_nonfinite_in_bbox(self): """ Regression test that nonfinite 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) class TestGetValues: """ Tests for the ApertureMask get_values method. """ def test_get_values(self): 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_get_values_no_overlap(self): aper = CircularAperture((-100, -100), r=3) data = np.ones((51, 51)) values = aper.to_mask().get_values(data) assert values.shape == (0,) def test_get_values_units(self): """ Test that the result is a Quantity with the data units for both the overlap and no-overlap cases. """ data = np.ones((51, 51)) * u.Jy values = CircularAperture((25, 25), r=3).to_mask().get_values(data) assert isinstance(values, u.Quantity) assert values.unit == u.Jy values = CircularAperture((-100, -100), r=3).to_mask().get_values( data) assert isinstance(values, u.Quantity) assert values.unit == u.Jy assert values.shape == (0,) def test_get_values_mask(self): 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) class TestAnnulusMasks: """ Tests for the masks produced by the annulus apertures. """ def test_rectangular_annulus_hin(self): 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 @pytest.mark.parametrize('method', ['center', 'subpixel', 'exact']) def test_annulus_mask_nonnegative(self, method): """ Regression test that annulus aperture masks never contain negative overlap fractions. An annulus overlap is computed as the difference of the outer and inner overlaps, which can leave a boundary pixel with a tiny negative value from floating-point noise. These must be clipped to zero. """ # Fractional positions place pixel centers near the annulus # edges, where the subtraction is prone to tiny negative # floating-point values. positions = [(24.3, 27.7), (25.55, 22.15), (28.1, 29.9)] apertures = [ CircularAnnulus(positions, r_in=3.0, r_out=6.0), EllipticalAnnulus(positions, a_in=3.0, a_out=6.0, b_out=4.0, theta=0.5), RectangularAnnulus(positions, w_in=3.0, w_out=6.0, h_out=4.0, theta=0.5), ] for aperture in apertures: for mask in aperture.to_mask(method=method): assert mask.data.min() >= 0.0 astropy-photutils-24be529/photutils/aperture/tests/test_photometry.py000066400000000000000000001310661526452531300264660ustar00rootroot00000000000000# 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.polygon import PolygonAperture from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.tests.conftest import NoBatchCircularAperture from photutils.datasets import make_wcs from photutils.utils._optional_deps import 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)) # The aperture position(s) used by the shape-specific photometry tests POSITION = (20.0, 20.0) POSITIONS = ((20.0, 20.0), (25.0, 25.0)) def expected_sum_err(aperture, error, *, method='exact', subpixels=5, mask=None): """ Compute the expected flux error ``sqrt(sum(w**2 * sigma**2))`` from the aperture mask, where ``w`` is the pixel overlap fraction. Pixels masked by ``mask`` do not contribute. A scalar is returned for a scalar aperture, otherwise an array. """ apermasks = aperture.to_mask(method=method, subpixels=subpixels) if aperture.isscalar: apermasks = [apermasks] errors = [] for apermask in apermasks: slc_large, slc_small = apermask.get_overlap_slices(error.shape) weights = apermask.data[slc_small].copy() if mask is not None: weights[mask[slc_large]] = 0.0 errors.append(np.sqrt(np.sum(weights**2 * error[slc_large] ** 2))) if aperture.isscalar: return errors[0] return np.array(errors) @pytest.fixture(name='ones_data') def fixture_ones_data(): """ A 40x40 image of ones. A fresh array is returned for each test, so it may be modified. """ return np.ones((40, 40), dtype=float) def _shape_aperture_specs(): """ Build the ``(aperture, area)`` pairs used by the shape-specific photometry tests, where ``area`` is the analytic aperture area. """ r = 10.0 r_in, r_out = 8.0, 10.0 a, b = 10.0, 5.0 a_in, a_out, b_out = 5.0, 8.0, 5.0 b_in = a_in * b_out / a_out w, h = 8.0, 5.0 w_in, w_out, h_out = 8.0, 12.0, 8.0 h_in = w_in * h_out / w_out polygon = PolygonAperture.from_regular_polygon(POSITION, 5, 6.0, theta=30 * u.deg) return [ pytest.param(CircularAperture(POSITION, r), np.pi * r**2, id='circle'), pytest.param(CircularAperture(POSITIONS, r), np.full(len(POSITIONS), np.pi * r**2), id='circle-array'), pytest.param(CircularAnnulus(POSITION, r_in, r_out), np.pi * (r_out**2 - r_in**2), id='circular-annulus'), pytest.param(CircularAnnulus(POSITIONS, r_in, r_out), np.full(len(POSITIONS), np.pi * (r_out**2 - r_in**2)), id='circular-annulus-array'), pytest.param(EllipticalAperture(POSITION, a, b, theta=-np.pi / 4.0), np.pi * a * b, id='ellipse'), pytest.param(EllipticalAnnulus(POSITION, a_in, a_out, b_out, theta=-np.pi / 4.0), np.pi * (a_out * b_out - a_in * b_in), id='elliptical-annulus'), pytest.param(RectangularAperture(POSITION, w, h, theta=np.pi / 4.0), w * h, id='rectangle'), pytest.param(RectangularAnnulus(POSITION, w_in, w_out, h_out, theta=np.pi / 8.0), w_out * h_out - w_in * h_in, id='rectangular-annulus'), pytest.param(polygon, polygon.area, id='polygon'), ] class TestShapePhotometry: """ Shape-specific photometry tests on an image of ones, comparing the center, subpixel, and exact methods against the analytic aperture area. """ @staticmethod def _check_photometry(data, aperture, area, *, mask=None, n_masked=0): """ Compare the center, subpixel, and exact photometry of ``data`` (an image of ones) against the analytic ``area``, of which ``n_masked`` unit pixels are masked by ``mask``. """ error = np.ones(data.shape, dtype=float) true_flux = area - n_masked table1 = aperture_photometry(data, aperture, method='center', mask=mask, error=error) table2 = aperture_photometry(data, aperture, method='subpixel', subpixels=12, mask=mask, error=error) table3 = aperture_photometry(data, aperture, method='exact', mask=mask, error=error) if not isinstance(aperture, (RectangularAperture, RectangularAnnulus)): assert_allclose(table3['aperture_sum'], true_flux) assert_allclose(table2['aperture_sum'], table3['aperture_sum'], atol=0.1) assert np.all(table1['aperture_sum'] < table3['aperture_sum']) for table, method, subpixels in ((table1, 'center', 5), (table2, 'subpixel', 12), (table3, 'exact', 5)): expected = expected_sum_err(aperture, error, method=method, subpixels=subpixels, mask=mask) assert_allclose(table['aperture_sum_err'], expected) # The partial boundary pixels contribute their variance weighted # by the squared overlap fraction, so the exact-method error is # strictly smaller than the sqrt of the aperture area. assert np.all(table3['aperture_sum_err'] < np.sqrt(area - n_masked)) @pytest.mark.parametrize(('aperture', 'area'), _shape_aperture_specs()) def test_array_error(self, ones_data, aperture, area): self._check_photometry(ones_data, aperture, area) def test_masked_pixel(self, ones_data): """ A masked pixel at the aperture center reduces the flux and the error accordingly. """ r = 10.0 aperture = CircularAperture(POSITION, r) mask = np.zeros(ones_data.shape, dtype=bool) mask[20, 20] = True self._check_photometry(ones_data, aperture, np.pi * r**2, mask=mask, n_masked=1) class TestInputNDData: """ Tests for photometry with `~astropy.nddata.NDData` input. """ radius = 3 position = ((20, 20), (30, 30)) true_flux = np.pi * radius * radius fluxunit = u.adu @pytest.fixture(name='input_data') def fixture_input_data(self, ones_data): """ An image of ones as an `~astropy.nddata.NDData` object with units. """ return NDData(ones_data, unit=self.fluxunit) def test_basic_circular_aperture_photometry(self, input_data): aperture = CircularAperture(self.position, self.radius) table = aperture_photometry(input_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]) TEST_ELLIPSE_EXACT_APERTURES = [(3.469906, 3.923861394, 3.0), (0.3834415188257778, 0.3834415188257778, 0.3)] @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class BaseTestRegionPhotometry: """ Tests that a region gives the same photometry as its equivalent aperture. Each subclass defines a ``region_aperture`` fixture returning the equivalent ``(region, aperture)`` pair. The ``regions`` package is imported within the fixture so that the module can be collected without it installed. """ def test_region_matches_aperture(self, ones_data, region_aperture): region, aperture = region_aperture error = np.ones(ones_data.shape, dtype=float) region_tables = [ aperture_photometry(ones_data, region, method='center', error=error), aperture_photometry(ones_data, region, method='subpixel', subpixels=12, error=error), aperture_photometry(ones_data, region, method='exact', error=error), ] aperture_tables = [ aperture_photometry(ones_data, aperture, method='center', error=error), aperture_photometry(ones_data, aperture, method='subpixel', subpixels=12, error=error), aperture_photometry(ones_data, 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']) assert_allclose(reg_table['aperture_sum_err'], ap_table['aperture_sum_err']) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestCircleRegionPhotometry(BaseTestRegionPhotometry): @pytest.fixture(name='region_aperture') def fixture_region_aperture(self): """ A circular region and the equivalent aperture. """ from regions import CirclePixelRegion, PixCoord r = 10.0 region = CirclePixelRegion(PixCoord(*POSITION), r) aperture = CircularAperture(POSITION, r) return region, aperture @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestCircleAnnulusRegionPhotometry(BaseTestRegionPhotometry): @pytest.fixture(name='region_aperture') def fixture_region_aperture(self): """ A circular annulus region and the equivalent aperture. """ from regions import CircleAnnulusPixelRegion, PixCoord r_in = 8.0 r_out = 10.0 region = CircleAnnulusPixelRegion(PixCoord(*POSITION), r_in, r_out) aperture = CircularAnnulus(POSITION, r_in, r_out) return region, aperture @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestEllipseRegionPhotometry(BaseTestRegionPhotometry): @pytest.fixture(name='region_aperture') def fixture_region_aperture(self): """ An elliptical region and the equivalent aperture. """ from regions import EllipsePixelRegion, PixCoord a = 10.0 b = 5.0 theta = (-np.pi / 4.0) * u.rad region = EllipsePixelRegion(PixCoord(*POSITION), a * 2, b * 2, theta) aperture = EllipticalAperture(POSITION, a, b, theta=theta) return region, aperture @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestEllipseAnnulusRegionPhotometry(BaseTestRegionPhotometry): @pytest.fixture(name='region_aperture') def fixture_region_aperture(self): """ An elliptical annulus region and the equivalent aperture. """ from regions import EllipseAnnulusPixelRegion, PixCoord a_in = 5.0 a_out = 8.0 b_in = 3.0 b_out = 5.0 theta = (-np.pi / 4.0) * u.rad region = EllipseAnnulusPixelRegion(PixCoord(*POSITION), a_in * 2, a_out * 2, b_in * 2, b_out * 2, theta) aperture = EllipticalAnnulus(POSITION, a_in, a_out, b_out, b_in=b_in, theta=theta) return region, aperture @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestRectangleRegionPhotometry(BaseTestRegionPhotometry): @pytest.fixture(name='region_aperture') def fixture_region_aperture(self): """ A rectangular region and the equivalent aperture. """ from regions import PixCoord, RectanglePixelRegion w = 8.0 h = 5.0 theta = (np.pi / 4.0) * u.rad region = RectanglePixelRegion(PixCoord(*POSITION), w, h, theta) aperture = RectangularAperture(POSITION, w, h, theta=theta) return region, aperture @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestRectangleAnnulusRegionPhotometry(BaseTestRegionPhotometry): @pytest.fixture(name='region_aperture') def fixture_region_aperture(self): """ A rectangular annulus region and the equivalent aperture. """ from regions import PixCoord, RectangleAnnulusPixelRegion w_in = 8.0 w_out = 12.0 h_out = 8.0 h_in = w_in * h_out / w_out theta = (np.pi / 8.0) * u.rad region = RectangleAnnulusPixelRegion(PixCoord(*POSITION), w_in, w_out, h_in, h_out, theta) aperture = RectangularAnnulus(POSITION, w_in, w_out, h_out, h_in=h_in, theta=theta) return region, aperture class TestArrayBounds: """ Tests for apertures inside, outside, and straddling the data. """ @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_outside_array(self, 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(self, 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) def test_pixel_positions(self): """ A scalar position yields a (2,) positions array, while a list containing one position yields a (1, 2) array. """ pos1 = (10, 20) pos2 = [(10, 20)] r = 3 ap1 = CircularAperture(pos1, r) ap2 = CircularAperture(pos2, r) assert ap1.positions.shape == (2,) assert ap2.positions.shape == (1, 2) assert_allclose(ap1.positions, ap2.positions[0]) def test_partial_overlap(self): 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_scalar_aperture(self): """ 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(self): """ Regression test that nonfinite 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']) class TestPlots: """ Tests for plotting apertures. """ @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_plots(self, aperture_class, params): import matplotlib.pyplot as plt aperture = aperture_class((20.0, 20.0), **params) try: patches = aperture.plot() assert len(patches) == 1 finally: plt.close('all') class TestWcsInput: """ Tests for photometry with a WCS and sky apertures. """ def test_input_wcs(self, data): 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(self, data): 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_scalar_skycoord(self, data): """ Regression test to check that scalar SkyCoords are added to the table as a length-1 SkyCoord array. """ 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) class TestUnitsAndMasking: """ Tests for Quantity inputs, masks, and nonfinite values. """ def test_data_units(self): 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_error_units(self): """ 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) aperture = CircularAperture(position, radius) table1 = aperture_photometry(data2, aperture, error=error) assert_allclose(table1['aperture_sum'].value, true_flux) assert_allclose(table1['aperture_sum_err'].value, expected_sum_err(aperture, data1)) assert table1['aperture_sum'].unit == unit assert table1['aperture_sum_err'].unit == unit def test_mask(self): """ 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) @pytest.mark.parametrize('value', [np.nan, np.inf]) def test_nan_inf_mask(self, 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) @pytest.mark.parametrize('units', [False, True]) def test_nddata_input(self, 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) class TestExactOverlap: """ Tests for the exact overlap grid and the aperture bounding boxes. """ @pytest.mark.parametrize(('x', 'y', 'r'), TEST_ELLIPSE_EXACT_APERTURES) def test_ellipse_exact_grid(self, 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) def test_rectangular_bbox(self): # 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(self): # 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) class TestApertureRepr: """ Tests for the aperture repr and str output. """ def test_pixel(self): 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 class TestInputValidation: """ Tests for the aperture_photometry input validation. """ def test_invalid_inputs(self): 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' for subpixels in (0, -1, 2.5, True): with pytest.raises(ValueError, match=match): aperture_photometry(data, aper, method='subpixel', subpixels=subpixels) def test_mask_validation(self): """ Test that the mask must be a boolean array with the same shape as the data. """ data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) # A nested list of bools is converted to a boolean array mask_list = [[False] * 11 for _ in range(11)] tbl1 = aperture_photometry(data, aper, mask=np.array(mask_list)) tbl2 = aperture_photometry(data, aper, mask=mask_list) assert_allclose(tbl2['aperture_sum'], tbl1['aperture_sum']) match = 'mask must be a boolean array' with pytest.raises(TypeError, match=match): aperture_photometry(data, aper, mask=np.zeros(data.shape, dtype=int)) match = 'mask and data must have the same shape' with pytest.raises(ValueError, match=match): aperture_photometry(data, aper, mask=np.zeros((3, 3), dtype=bool)) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_unsupported_region_input(self): from regions import LinePixelRegion, PixCoord region = LinePixelRegion(start=PixCoord(x=1, y=1), end=PixCoord(x=5, y=5)) data = np.ones((10, 10)) match = r'Cannot convert .* to an Aperture object' with pytest.raises(TypeError, match=match): aperture_photometry(data, region) class TestApertureMetadata: """ Tests for the aperture metadata stored in the output table. """ def test_metadata(self): 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 class TestPartialPixelErrorWeights: """ Tests that flux errors weight the pixel variances by the squared overlap fractions. The reported flux is sum(w * data) over the aperture pixels, where ``w`` is the pixel overlap fraction. With independent pixel errors, its variance is sum(w**2 * sigma**2), so boundary pixels must contribute their variance weighted by the squared fraction. """ @pytest.fixture(name='scene') def fixture_scene(self): """ A deterministic image and nonuniform error array. """ rng = np.random.default_rng(seed=123) data = rng.normal(10.0, 1.0, (25, 25)) error = rng.uniform(0.5, 1.5, (25, 25)) return data, error @pytest.mark.parametrize(('method', 'subpixels'), [('exact', 5), ('subpixel', 3)]) def test_batch_path(self, scene, method, subpixels): data, error = scene aperture = CircularAperture((12.2, 11.7), 3.3) table = aperture_photometry(data, aperture, error=error, method=method, subpixels=subpixels) expected = expected_sum_err(aperture, error, method=method, subpixels=subpixels) assert_allclose(table['aperture_sum_err'][0], expected) @pytest.mark.parametrize(('method', 'subpixels'), [('exact', 5), ('subpixel', 3)]) def test_mask_path(self, scene, method, subpixels): data, error = scene aperture = NoBatchCircularAperture((12.2, 11.7), 3.3) table = aperture_photometry(data, aperture, error=error, method=method, subpixels=subpixels) expected = expected_sum_err(aperture, error, method=method, subpixels=subpixels) assert_allclose(table['aperture_sum_err'][0], expected) astropy-photutils-24be529/photutils/aperture/tests/test_photometry_class.py000066400000000000000000001424351526452531300276550ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the AperturePhotometry class. """ from concurrent.futures import ThreadPoolExecutor from unittest.mock import patch import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.nddata import NDData, StdDevUncertainty from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAperture) from photutils.aperture.flags import APERTURE_FLAGS from photutils.aperture.photometry import (AperturePhotometry, aperture_photometry) from photutils.aperture.polygon import PolygonAperture from photutils.aperture.stats import ApertureStats from photutils.aperture.tests.conftest import (NoBatchCircularAperture, make_scene) from photutils.datasets import make_wcs from photutils.segmentation import SegmentationImage from photutils.utils._optional_deps import HAS_REGIONS class TestAperturePhotometryParity: """ The class must return results identical to the legacy ``aperture_photometry`` function for all shared inputs. """ def test_single_aperture(self, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper) ref = aperture_photometry(data, aper) assert_allclose(phot.flux, ref['aperture_sum']) def test_multiple_positions(self, data): aper = CircularAperture(((150, 25), (90, 60)), 10) phot = AperturePhotometry(data, aper) ref = aperture_photometry(data, aper) assert_allclose(phot.flux, ref['aperture_sum']) assert phot.flux.shape == (2,) def test_error_propagation(self, data): error = np.sqrt(np.abs(data)) aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper, error=error) ref = aperture_photometry(data, aper, error=error) assert_allclose(phot.flux, ref['aperture_sum']) assert_allclose(phot.flux_err, ref['aperture_sum_err']) def test_no_error_is_nan(self, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper) assert np.all(np.isnan(phot.flux_err)) def test_mask(self, data): mask = np.zeros(data.shape, dtype=bool) mask[25, 150] = True aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper, mask=mask) ref = aperture_photometry(data, aper, mask=mask) assert_allclose(phot.flux, ref['aperture_sum']) def test_nomask(self, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper, mask=np.ma.nomask) ref = aperture_photometry(data, aper) assert phot._mask is None assert_allclose(phot.flux, ref['aperture_sum']) @pytest.mark.parametrize(('method', 'subpixels'), [('exact', 5), ('center', 5), ('subpixel', 7)]) def test_method_variants(self, method, subpixels, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper, method=method, subpixels=subpixels) ref = aperture_photometry(data, aper, method=method, subpixels=subpixels) assert_allclose(phot.flux, ref['aperture_sum']) def test_units(self, data): data = data * u.Jy error = np.sqrt(np.abs(data.value)) * u.Jy aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper, error=error) ref = aperture_photometry(data, aper, error=error) assert phot.flux.unit == u.Jy assert phot.flux_err.unit == u.Jy assert phot.area.unit == u.pix**2 assert_allclose(phot.flux.value, ref['aperture_sum'].value) assert_allclose(phot.flux_err.value, ref['aperture_sum_err'].value) class TestListOfApertures: def test_flux_shape(self, data): pos = ((150, 25), (90, 60)) apers = [CircularAperture(pos, r) for r in (5, 8)] phot = AperturePhotometry(data, apers) assert phot.flux.shape == (2, 2) assert phot.area.shape == (2, 2) assert phot.flags.shape == (2, 2) def test_matches_per_aperture(self, data): pos = ((150, 25), (90, 60)) apers = [CircularAperture(pos, r) for r in (5, 8)] phot = AperturePhotometry(data, apers) for i, aper in enumerate(apers): ref = aperture_photometry(data, aper) assert_allclose(phot.flux[:, i], ref['aperture_sum']) def test_identical_positions_required(self, data): apers = [CircularAperture((150, 25), 5), CircularAperture((90, 60), 5)] match = 'Input apertures must all have identical positions' with pytest.raises(ValueError, match=match): AperturePhotometry(data, apers) class TestSkyApertures: def test_sky_center_from_pixel_wcs(self, data): wcs = make_wcs(data.shape) aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper, wcs=wcs) assert isinstance(phot.sky_center, SkyCoord) assert 'sky_center' in phot.to_table().colnames def test_no_wcs_sky_center_none(self, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper) assert phot.sky_center is None assert 'sky_center' not in phot.to_table().colnames def test_sky_aperture(self, data): wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(150, 25) sky_aper = SkyCircularAperture(skycoord, r=0.7 * u.arcsec) pix_aper = sky_aper.to_pixel(wcs) phot = AperturePhotometry(data, sky_aper, wcs=wcs) ref = aperture_photometry(data, pix_aper) assert_allclose(phot.flux, ref['aperture_sum']) assert isinstance(phot.sky_center, SkyCoord) assert phot.sky_center.isscalar is True def test_sky_aperture_multiple_positions(self, data): wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world([150, 90], [25, 60]) sky_aper = SkyCircularAperture(skycoord, r=0.7 * u.arcsec) pix_aper = sky_aper.to_pixel(wcs) phot = AperturePhotometry(data, sky_aper, wcs=wcs) ref = aperture_photometry(data, pix_aper) assert_allclose(phot.flux, ref['aperture_sum']) assert phot.sky_center.isscalar is False assert len(phot.sky_center) == 2 def test_sky_aperture_requires_wcs(self): data = np.ones((11, 11)) wcs = make_wcs(data.shape) sky_aper = CircularAperture((5, 5), r=3).to_sky(wcs=wcs) match = 'A WCS transform must be defined' with pytest.raises(ValueError, match=match): AperturePhotometry(data, sky_aper) class TestNDDataInput: def test_nddata(self, data): error = np.sqrt(np.abs(data)) uncertainty = StdDevUncertainty(error) nddata = NDData(data, uncertainty=uncertainty) aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(nddata, aper) ref = aperture_photometry(data, aper, error=error) assert_allclose(phot.flux, ref['aperture_sum']) assert_allclose(phot.flux_err, ref['aperture_sum_err']) def test_nddata_ignored_keywords_warn(self, data): nddata = NDData(data) aper = CircularAperture((150, 25), 8) mask = np.zeros(data.shape, dtype=bool) match = 'is obtained from the input NDData object' with pytest.warns(AstropyUserWarning, match=match): AperturePhotometry(nddata, aper, mask=mask) def test_nddata_error_keyword_is_ignored(self, data): """ Test that the ``error`` keyword is ignored, as warned, when the NDData object has no StdDevUncertainty, matching the handling of the ``mask`` and ``wcs`` keywords. """ nddata = NDData(data) aper = CircularAperture((150, 25), 8) error = np.sqrt(np.abs(data)) match = 'is obtained from the input NDData object' with pytest.warns(AstropyUserWarning, match=match): phot = AperturePhotometry(nddata, aper, error=error) assert np.isnan(phot.flux_err) with pytest.warns(AstropyUserWarning, match=match): stats = ApertureStats(nddata, aper, error=error) assert np.isnan(stats.sum_err) def test_nddata_units(self, data): nddata = NDData(data * u.Jy) aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(nddata, aper) assert phot.flux.unit == u.Jy def test_nddata_uncertainty_with_unit(self, data): error = np.sqrt(np.abs(data)) uncertainty = StdDevUncertainty(error, unit=u.Jy) nddata = NDData(data * u.Jy, uncertainty=uncertainty) aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(nddata, aper) ref = aperture_photometry(data * u.Jy, aper, error=error * u.Jy) assert phot.flux_err.unit == u.Jy assert_allclose(phot.flux_err.value, ref['aperture_sum_err'].value) class TestSegmentationMasking: def test_mask_method_matches_manual(self): data, segm = make_scene() aper = CircularAperture([(21, 21)], r=8) segm_in = SegmentationImage(segm) phot = AperturePhotometry(data, aper, segmentation_image=segm_in, labels=[1], mask_method='mask') manual_mask = (segm > 0) & (segm != 1) ref = AperturePhotometry(data, aper, mask=manual_mask) assert_allclose(phot.flux, ref.flux) def test_source_only_matches_manual(self): data, segm = make_scene() aper = CircularAperture([(21, 21)], r=8) phot = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=[1], mask_method='source_only') manual_mask = segm != 1 ref = AperturePhotometry(data, aper, mask=manual_mask) assert_allclose(phot.flux, ref.flux) def test_background_only_matches_manual(self): data, segm = make_scene() aper = CircularAperture([(21, 21)], r=8) phot = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), mask_method='background_only') ref = AperturePhotometry(data, aper, mask=segm > 0) assert_allclose(phot.flux, ref.flux) assert phot.labels is None def test_none_method_ignores_segmentation(self): data, segm = make_scene() aper = CircularAperture([(21, 21)], r=8) phot = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), mask_method='none') ref = AperturePhotometry(data, aper) assert_allclose(phot.flux, ref.flux) def test_correct_matches_manual_mirror(self): """ Test that mask_method='correct' reproduces plain photometry on a manually mirror-corrected image. The scene is constructed so that the mirror of every neighbor pixel is an unmasked nonneighbor pixel, so every neighbor pixel is corrected (none are excluded). """ data, segm = make_scene() xycen = (21, 21) aper = CircularAperture([xycen], r=10) corr_phot = AperturePhotometry( data, aper, segmentation_image=SegmentationImage(segm), labels=[1], mask_method='correct') # Replace every neighbor pixel with its value mirrored across # the aperture center. Pixels outside the aperture have zero # weight, so correcting them globally does not change the flux. corrected = data.copy() yidx, xidx = np.nonzero((segm > 0) & (segm != 1)) corrected[yidx, xidx] = data[2 * xycen[1] - yidx, 2 * xycen[0] - xidx] ref_phot = AperturePhotometry(corrected, aper) assert_allclose(corr_phot.flux, ref_phot.flux, rtol=1e-12) # The correction changes the flux relative to no masking none_phot = AperturePhotometry(data, aper, mask_method='none') assert corr_phot.flux[0] != none_phot.flux[0] def test_polygon_mask_path(self): data, segm = make_scene() offsets = np.array([[-7, -7], [9, -7], [9, 9], [-7, 9]]) aper = PolygonAperture((21, 21), offsets) phot = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=[1], mask_method='mask') manual_mask = (segm > 0) & (segm != 1) ref = AperturePhotometry(data, aper, mask=manual_mask) assert_allclose(phot.flux, ref.flux) def test_labels_required(self): data, segm = make_scene() aper = CircularAperture([(21, 21)], r=8) match = 'labels must be input when segmentation_image is input' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), mask_method='mask') class TestToTable: def test_default_columns(self, data): aper = CircularAperture((150, 25), 8) tbl = AperturePhotometry(data, aper).to_table() assert tbl.colnames == ['id', 'x_center', 'y_center', 'flux', 'flux_err', 'area', 'flags'] def test_default_columns_with_wcs(self, data): wcs = make_wcs(data.shape) aper = CircularAperture((150, 25), 8) tbl = AperturePhotometry(data, aper, wcs=wcs).to_table() assert tbl.colnames == ['id', 'x_center', 'y_center', 'sky_center', 'flux', 'flux_err', 'area', 'flags'] def test_columns_subset(self, data): aper = CircularAperture((150, 25), 8) tbl = AperturePhotometry(data, aper).to_table(columns=['id', 'flux']) assert tbl.colnames == ['id', 'flux'] def test_columns_single_string(self, data): aper = CircularAperture((150, 25), 8) tbl = AperturePhotometry(data, aper).to_table(columns='flux') assert tbl.colnames == ['flux'] def test_invalid_column(self, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper) match = 'Invalid column name' with pytest.raises(ValueError, match=match): phot.to_table(columns='invalid') with pytest.raises(ValueError, match=match): phot.to_table(columns=['id', 'subpixels']) def test_multi_aperture_suffixes(self, data): pos = ((150, 25), (90, 60)) apers = [CircularAperture(pos, r) for r in (5, 8)] tbl = AperturePhotometry(data, apers).to_table() assert tbl.colnames == ['id', 'x_center', 'y_center', 'flux_0', 'flux_1', 'flux_err_0', 'flux_err_1', 'area_0', 'area_1', 'flags_0', 'flags_1'] def test_meta(self, data): aper = CircularAperture((150, 25), 8) tbl = AperturePhotometry(data, aper).to_table() assert 'version' in tbl.meta assert 'aperture_photometry_args' in tbl.meta assert tbl.meta['aperture'] == 'CircularAperture' def test_sky_center_requires_wcs(self, data): """ Test that requesting the sky_center column without a WCS raises a clear error. """ aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper) match = "the 'sky_center' column requires a WCS" with pytest.raises(ValueError, match=match): phot.to_table(columns=['id', 'sky_center']) class TestFlagsAndArea: def test_area_matches_area_overlap(self): data = np.ones((25, 25), dtype=float) aper = CircularAperture([(10, 10), (15, 15)], r=4.0) phot = AperturePhotometry(data, aper) assert phot.area.unit == u.pix**2 assert_allclose(phot.area.value, aper.area_overlap(data)) def test_partial_overlap_flag(self): data = np.ones((25, 25), dtype=float) aper = CircularAperture((0, 12), r=5.0) phot = AperturePhotometry(data, aper) assert phot.flags == APERTURE_FLAGS.PARTIAL_OVERLAP def test_decode_flags(self): data = np.ones((25, 25)) mask = np.zeros(data.shape, dtype=bool) mask[12, 12] = True aper = CircularAperture([(12.0, 12.0), (0.0, 12.0)], r=3.0) phot = AperturePhotometry(data, aper, mask=mask) decoded = phot.decode_flags() assert list(decoded) == [1, 2] assert all(type(key) is int for key in decoded) assert decoded[1] == ['masked_pixels'] assert decoded[2] == ['partial_overlap'] def test_decode_flags_bit_values(self): data = np.ones((25, 25)) mask = np.zeros(data.shape, dtype=bool) mask[12, 12] = True aper = CircularAperture([(12.0, 12.0)], r=3.0) phot = AperturePhotometry(data, aper, mask=mask) decoded = phot.decode_flags(return_bit_values=True) assert decoded == {1: [8]} def test_decode_flags_multiple_apertures(self): data = np.ones((25, 25)) mask = np.zeros(data.shape, dtype=bool) mask[12, 12] = True positions = [(12.0, 12.0), (0.0, 12.0)] apers = [CircularAperture(positions, r=3.0), CircularAperture(positions, r=5.0)] phot = AperturePhotometry(data, apers, mask=mask) decoded = phot.decode_flags() assert list(decoded) == [1, 2] assert decoded[1] == [['masked_pixels'], ['masked_pixels']] assert decoded[2] == [['partial_overlap'], ['partial_overlap']] class TestScalarBehavior: """ A single scalar aperture position yields scalar output attributes, while array positions yield array outputs. """ def test_single_scalar_position(self, data): error = 0.1 * np.ones_like(data) aper = CircularAperture((150, 25), r=8) phot = AperturePhotometry(data, aper, error=error) assert phot.isscalar is True assert phot.n_positions == 1 for attr in ('id', 'x_center', 'y_center', 'flux', 'flux_err'): assert np.ndim(getattr(phot, attr)) == 0 assert phot.area.isscalar assert np.ndim(phot.flags) == 0 def test_array_position_single_element(self, data): # A length-1 list of positions is not scalar. aper = CircularAperture([(150, 25)], r=8) phot = AperturePhotometry(data, aper) assert phot.isscalar is False assert phot.flux.shape == (1,) assert phot.flux_err.shape == (1,) assert phot.area.shape == (1,) assert phot.flags.shape == (1,) def test_multiple_positions_not_scalar(self, data): aper = CircularAperture(((150, 25), (90, 60)), r=8) phot = AperturePhotometry(data, aper) assert phot.isscalar is False assert phot.flux.shape == (2,) def test_scalar_matches_array(self, data): """ Test that the scalar output from a single position matches the first element of the array output from a length-1 list of positions. """ scalar = AperturePhotometry(data, CircularAperture((150, 25), r=8)) array = AperturePhotometry(data, CircularAperture([(150, 25)], r=8)) assert_allclose(scalar.flux, array.flux[0]) assert scalar.flags == array.flags[0] assert_allclose(scalar.x_center, array.x_center[0]) def test_list_of_apertures_scalar_position(self, data): apers = [CircularAperture((150, 25), r=r) for r in (5, 8)] phot = AperturePhotometry(data, apers) assert phot.isscalar is True # id/x_center/y_center collapse to scalars assert np.ndim(phot.id) == 0 assert np.ndim(phot.x_center) == 0 assert np.ndim(phot.y_center) == 0 # The per-aperture attributes keep only the trailing aperture axis assert phot.flux.shape == (2,) assert phot.flux_err.shape == (2,) assert phot.area.shape == (2,) assert phot.flags.shape == (2,) def test_list_of_apertures_array_position(self, data): pos = ((150, 25), (90, 60)) apers = [CircularAperture(pos, r=r) for r in (5, 8)] phot = AperturePhotometry(data, apers) assert phot.isscalar is False assert phot.flux.shape == (2, 2) def test_sky_center_scalar(self, data): wcs = make_wcs(data.shape) phot = AperturePhotometry(data, CircularAperture((150, 25), r=8), wcs=wcs) assert isinstance(phot.sky_center, SkyCoord) assert phot.sky_center.isscalar is True def test_sky_center_array(self, data): wcs = make_wcs(data.shape) phot = AperturePhotometry(data, CircularAperture([(150, 25)], r=8), wcs=wcs) assert phot.sky_center.isscalar is False assert len(phot.sky_center) == 1 def test_to_table_scalar_single_aperture(self, data): aper = CircularAperture((150, 25), r=8) tbl = AperturePhotometry(data, aper).to_table() assert len(tbl) == 1 assert tbl.colnames == ['id', 'x_center', 'y_center', 'flux', 'flux_err', 'area', 'flags'] def test_to_table_scalar_list_of_apertures(self, data): apers = [CircularAperture((150, 25), r=r) for r in (5, 8)] tbl = AperturePhotometry(data, apers).to_table() assert len(tbl) == 1 assert tbl.colnames == ['id', 'x_center', 'y_center', 'flux_0', 'flux_1', 'flux_err_0', 'flux_err_1', 'area_0', 'area_1', 'flags_0', 'flags_1'] def test_decode_flags_scalar(self): data = np.ones((25, 25)) mask = np.zeros(data.shape, dtype=bool) mask[12, 12] = True aper = CircularAperture((12, 12), r=3) phot = AperturePhotometry(data, aper, mask=mask) assert phot.decode_flags() == {1: ['masked_pixels']} def test_scalar_input_attributes_not_collapsed(self): """ Test that the ``segmentation_image`` and ``labels`` inputs are echoed back unchanged for a scalar instance, i.e., that they are not treated as per-position output arrays. """ data, segm = make_scene() segm_in = SegmentationImage(segm) aper = CircularAperture((21, 21), r=8) phot = AperturePhotometry(data, aper, segmentation_image=segm_in, labels=np.array([1]), mask_method='mask') assert phot.isscalar is True assert phot.segmentation_image is segm_in assert_equal(phot.labels, np.array([1])) # The photometry itself is unaffected ref = AperturePhotometry(data, aper, mask=(segm > 0) & (segm != 1)) assert_allclose(phot.flux, ref.flux) def test_isscalar_matches_aperture_stats(self, data): scalar_aper = CircularAperture((150, 25), r=8) array_aper = CircularAperture([(150, 25)], r=8) assert (AperturePhotometry(data, scalar_aper).isscalar == ApertureStats(data, scalar_aper).isscalar) assert (AperturePhotometry(data, array_aper).isscalar == ApertureStats(data, array_aper).isscalar) class TestNonFiniteData: """ Nonfinite ``data`` values (NaN and inf) must be automatically masked (excluded from flux, flux_err, and area) and reported via the ``non_finite_data`` flag, mirroring ``ApertureStats``. """ def test_batch_path_masks_nonfinite(self): data = np.ones((25, 25)) data[12, 12] = np.nan data[11, 11] = np.inf aper = CircularAperture((12, 12), r=5) phot = AperturePhotometry(data, aper) # The two nonfinite interior pixels (each of weight 1) are # excluded from the flux and area (all other pixels are 1.0). assert np.isfinite(phot.flux) assert_allclose(phot.flux, aper.area_overlap(data) - 2) assert_allclose(phot.area.value, phot.flux) assert phot.flags == APERTURE_FLAGS.NON_FINITE_DATA assert phot.decode_flags()[1] == ['non_finite_data'] def test_mask_path_masks_nonfinite(self): # Disable the batch Cython driver to exercise the slower # mask-based code path (via a spec of None). aper = CircularAperture((12, 12), r=5) data = np.ones((25, 25)) data[12, 12] = np.nan data[11, 11] = np.inf with patch.object(CircularAperture, '_batch_shape_params', lambda _self: None): phot = AperturePhotometry(data, aper) flux = phot.flux flags = phot.flags decoded = phot.decode_flags()[1] assert np.isfinite(flux) assert_allclose(flux, aper.area_overlap(data) - 2) assert flags == APERTURE_FLAGS.NON_FINITE_DATA assert decoded == ['non_finite_data'] @pytest.mark.parametrize('aperture_type', ['circle', 'polygon']) def test_parity_with_aperture_stats(self, aperture_type): data = np.ones((25, 25)) data[12, 12] = np.nan data[11, 11] = np.inf if aperture_type == 'circle': aper = CircularAperture((12, 12), r=5) else: offsets = np.array([[-5, -5], [5, -5], [5, 5], [-5, 5]]) aper = PolygonAperture((12, 12), offsets) phot = AperturePhotometry(data, aper) stats = ApertureStats(data, aper) assert_allclose(phot.flux, stats.sum) assert_allclose(phot.area.value, stats.sum_aper_area.value) assert phot.flags == stats.flags def test_nonfinite_outside_aperture_not_flagged(self): data = np.ones((25, 25)) data[0, 0] = np.nan # far outside the aperture aper = CircularAperture((12, 12), r=5) phot = AperturePhotometry(data, aper) assert phot.flags == 0 assert_allclose(phot.flux, aper.area_overlap(data)) def test_combined_mask_and_nonfinite(self): data = np.ones((25, 25)) data[12, 12] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[11, 12] = True aper = CircularAperture((12, 12), r=5) phot = AperturePhotometry(data, aper, mask=mask) assert np.isfinite(phot.flux) # Both masked_pixels and non_finite_data are set assert phot.flags == (APERTURE_FLAGS.MASKED_PIXELS | APERTURE_FLAGS.NON_FINITE_DATA) assert set(phot.decode_flags()[1]) == {'masked_pixels', 'non_finite_data'} def test_nonfinite_at_masked_pixel_counts_as_masked(self): # A nonfinite pixel that is also input-masked is reported as # masked_pixels, not non_finite_data (matching ApertureStats). data = np.ones((25, 25)) data[12, 12] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[12, 12] = True aper = CircularAperture((12, 12), r=5) phot = AperturePhotometry(data, aper, mask=mask) assert phot.flags == APERTURE_FLAGS.MASKED_PIXELS stats = ApertureStats(data, aper, mask=mask) assert phot.flags == stats.flags def test_legacy_function_still_corrupts_nonfinite(self): """ Test that the legacy aperture_photometry function still returns NaN for the aperture sum when there are nonfinite values in the aperture, even though the new AperturePhotometry class masks them and returns a finite sum. This is to ensure backward compatibility with existing code that relies on the old behavior. """ data = np.ones((25, 25)) data[12, 12] = np.nan aper = CircularAperture((12, 12), r=5) tbl = aperture_photometry(data, aper) assert np.isnan(tbl['aperture_sum'][0]) offsets = np.array([[-5, -5], [5, -5], [5, 5], [-5, 5]]) poly = PolygonAperture((12, 12), offsets) tbl = aperture_photometry(data, poly) assert np.isnan(tbl['aperture_sum'][0]) class TestInputValidation: def test_data_not_2d(self): aper = CircularAperture((5, 5), r=3) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): AperturePhotometry(np.ones((5, 5, 5)), aper) def test_error_shape_mismatch(self): data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) match = 'data and error must have the same shape' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, error=np.ones((5, 5))) def test_mask_shape_mismatch(self): data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, mask=np.zeros((5, 5), dtype=bool)) def test_unit_mismatch(self): data = np.ones((11, 11)) * u.Jy aper = CircularAperture((5, 5), r=3) match = 'must all have the same units' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, error=np.ones((11, 11))) # The converse: unitless data with a unit error with pytest.raises(ValueError, match=match): AperturePhotometry(np.ones((11, 11)), aper, error=np.ones((11, 11)) * u.Jy) # Same-dimension but different units are also rejected with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, error=np.ones((11, 11)) * u.mJy) @pytest.mark.parametrize('method', ['exact ', 'Exact', 'invalid']) def test_invalid_method_at_init(self, method): """ Test that an invalid method is reported at construction rather than at the first access of a measured attribute. """ data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) match = f'Invalid method: {method!r}' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, method=method) match = f'Invalid sum_method: {method!r}' with pytest.raises(ValueError, match=match): ApertureStats(data, aper, sum_method=method) @pytest.mark.parametrize('int_type', [np.int64, np.int32, np.uint8]) def test_numpy_integer_subpixels(self, int_type): """ Test that a NumPy integer subpixels value is accepted. """ data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) kwargs = {'method': 'subpixel'} expected = AperturePhotometry(data, aper, subpixels=5, **kwargs) result = AperturePhotometry(data, aper, subpixels=int_type(5), **kwargs) assert_allclose(result.flux, expected.flux) stats = ApertureStats(data, aper, sum_method='subpixel', subpixels=int_type(5)) assert_allclose(stats.sum, expected.flux) mask = aper.to_mask(method='subpixel', subpixels=int_type(5)) assert_allclose(mask.data.sum(), expected.flux) @pytest.mark.parametrize('subpixels', [0, -1, 2.5, True, np.True_]) def test_invalid_subpixels_at_init(self, subpixels): """ Test that an invalid subpixels value is reported at construction. """ 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): AperturePhotometry(data, aper, method='subpixel', subpixels=subpixels) with pytest.raises(ValueError, match=match): ApertureStats(data, aper, sum_method='subpixel', subpixels=subpixels) def test_invalid_mask_method_at_init(self): data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) match = 'mask_method must be one of' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, mask_method='invalid') with pytest.raises(ValueError, match=match): ApertureStats(data, aper, mask_method='invalid') @pytest.mark.parametrize('apertures', [[], (), np.array([])]) def test_empty_aperture_list(self, apertures): """ Test that an empty aperture list is reported at construction. """ data = np.ones((11, 11)) match = 'apertures must not be empty' with pytest.raises(ValueError, match=match): AperturePhotometry(data, apertures) def test_labels_not_1d(self): """ Test that a 2D labels array is reported at construction rather than failing later in the batch driver. """ data = np.ones((11, 11)) segm = np.zeros(data.shape, dtype=int) segm[4:7, 4:7] = 1 aper = CircularAperture((5, 5), r=3) match = 'labels must be a 1D array' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=np.array([[1, 2]]), mask_method='mask') with pytest.raises(ValueError, match=match): ApertureStats(data, aper, segmentation_image=SegmentationImage(segm), labels=np.array([[1, 2]]), mask_method='mask') class TestReadOnlyInputs: """ End-to-end tests that both classes accept read-only (nonwritable) input arrays on both the batch and mask-based code paths, and that the inputs are never modified. """ @staticmethod def _make_readonly_inputs(): """ Build a full set of read-only input arrays, including a masked pixel and a nonfinite data value. """ data, segm = make_scene() rng = np.random.default_rng(7) data = data + rng.normal(0.0, 0.1, data.shape) data[30, 5] = np.nan error = np.full(data.shape, 0.1) mask = np.zeros(data.shape, dtype=bool) mask[20, 20] = True arrays = {'data': data, 'error': error, 'mask': mask, 'segmentation_image': segm.astype(np.intp), 'labels': np.array([1]), 'local_bkg': np.array([1.0]), 'positions': np.array([(21.0, 21.0)])} for arr in arrays.values(): arr.setflags(write=False) return arrays @pytest.mark.parametrize('aper_cls', [CircularAperture, NoBatchCircularAperture]) @pytest.mark.parametrize('mask_method', ['none', 'mask', 'source_only', 'background_only', 'correct']) def test_aperture_photometry(self, aper_cls, mask_method): arrays = self._make_readonly_inputs() originals = {key: arr.copy() for key, arr in arrays.items()} kwargs = {} if mask_method != 'none': segm = SegmentationImage(arrays['segmentation_image']) kwargs = {'segmentation_image': segm, 'labels': arrays['labels'], 'mask_method': mask_method} aper = aper_cls(arrays['positions'], r=10) phot = AperturePhotometry(arrays['data'], aper, error=arrays['error'], mask=arrays['mask'], **kwargs) assert np.isfinite(phot.flux[0]) for attr in ('flux_err', 'area', 'flags'): _ = getattr(phot, attr) _ = phot.to_table() for key, arr in arrays.items(): assert_equal(arr, originals[key]) @pytest.mark.parametrize('aper_cls', [CircularAperture, NoBatchCircularAperture]) @pytest.mark.parametrize('with_sigma_clip', [False, True]) def test_aperture_stats(self, aper_cls, with_sigma_clip): arrays = self._make_readonly_inputs() originals = {key: arr.copy() for key, arr in arrays.items()} sigma_clip = (SigmaClip(sigma=3.0, maxiters=5) if with_sigma_clip else None) aper = aper_cls(arrays['positions'], r=10) segm = SegmentationImage(arrays['segmentation_image']) stats = ApertureStats(arrays['data'], aper, error=arrays['error'], mask=arrays['mask'], sigma_clip=sigma_clip, local_bkg=arrays['local_bkg'], segmentation_image=segm, labels=arrays['labels'], mask_method='correct') for attr in ('sum', 'sum_err', 'mean', 'median', 'std', 'mad_std', 'biweight_location', 'gini', 'centroid', 'semimajor_axis', 'fwhm', 'flags'): _ = getattr(stats, attr) assert np.isfinite(stats.mean) _ = stats.to_table() for key, arr in arrays.items(): assert_equal(arr, originals[key]) class TestEmptyPositions: """ Regression tests for apertures with zero positions, which must flow through both classes (including the batch Cython drivers) and the legacy function, returning empty outputs. """ def test_aperture_photometry_class(self): data = np.ones((11, 11)) aper = CircularAperture(np.empty((0, 2)), r=3) phot = AperturePhotometry(data, aper, error=np.ones_like(data)) assert phot.n_positions == 0 assert phot.flux.shape == (0,) assert phot.flux_err.shape == (0,) assert phot.area.shape == (0,) assert phot.flags.shape == (0,) assert len(phot.to_table()) == 0 def test_aperture_stats(self): data = np.ones((11, 11)) aper = CircularAperture(np.empty((0, 2)), r=3) stats = ApertureStats(data, aper) assert stats.n_positions == 0 assert stats.mean.shape == (0,) assert stats.median.shape == (0,) assert stats.sum.shape == (0,) assert stats.flags.shape == (0,) assert len(stats.to_table()) == 0 def test_legacy_function(self): data = np.ones((11, 11)) aper = CircularAperture(np.empty((0, 2)), r=3) tbl = aperture_photometry(data, aper) assert len(tbl) == 0 class TestSegmentationAttributes: """ Both classes echo the segmentation-masking inputs back as public attributes. """ @pytest.mark.parametrize('cls', [AperturePhotometry, ApertureStats]) def test_defaults(self, cls): data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) obj = cls(data, aper) assert obj.segmentation_image is None assert obj.labels is None assert obj.mask_method == 'none' @pytest.mark.parametrize('cls', [AperturePhotometry, ApertureStats]) def test_inputs_echoed(self, cls): data, segm = make_scene() segm = SegmentationImage(segm) aper = CircularAperture([(21, 21)], r=8) obj = cls(data, aper, segmentation_image=segm, labels=[1], mask_method='mask') assert obj.segmentation_image is segm assert_equal(obj.labels, [1]) assert obj.mask_method == 'mask' def test_stats_slicing_slices_labels(self): """ Test that slicing an ApertureStats also slices the per-aperture ``labels``, so the sliced object reports the labels of the apertures it contains. """ data, segm = make_scene() segm = SegmentationImage(segm) aper = CircularAperture([(21, 21), (21, 21)], r=8) stats = ApertureStats(data, aper, segmentation_image=segm, labels=[1, 2], mask_method='mask') assert_equal(stats.labels, [1, 2]) assert_equal(stats[1:].labels, [2]) assert stats[0].labels == 1 assert stats[0].mask_method == 'mask' assert stats[0].segmentation_image is segm def test_photometry_meta_records_mask_method(self): data, segm = make_scene() aper = CircularAperture([(21, 21)], r=8) phot = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=[1], mask_method='mask') args = phot.to_table().meta['aperture_photometry_args'] assert "method='exact'" in args assert 'subpixels=5' in args assert "mask_method='mask'" in args class TestReprAndImmutability: def test_repr(self, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper) assert 'AperturePhotometry' in repr(phot) assert "method='exact'" in repr(phot) def test_str(self, data): aper = CircularAperture((150, 25), 8) phot = AperturePhotometry(data, aper) assert 'AperturePhotometry' in str(phot) def test_no_new_attributes_after_init(self, data): """ Only cached-property cache entries may appear after ``__init__``, which is required for the instance to be thread-safe. """ aper = CircularAperture(((150, 25), (90, 60)), 8) phot = AperturePhotometry(data, aper, error=np.ones_like(data), wcs=make_wcs(data.shape)) init_keys = set(vars(phot).keys()) # Touch every public and private lazy property/attribute for name in ('id', 'x_center', 'y_center', 'sky_center', 'flux', 'flux_err', 'area', 'flags', 'n_positions'): getattr(phot, name) phot.to_table() phot.decode_flags() new_keys = set(vars(phot).keys()) - init_keys lazy_names = {'_photometry_results', '_positions', 'n_positions', 'id', 'x_center', 'y_center', 'sky_center', 'flux', 'flux_err', 'area', 'flags', 'isscalar'} assert new_keys.issubset(lazy_names) def test_concurrent_access(self, data): """ Test that a single shared AperturePhotometry instance can be read concurrently. The cached-property caches fill under contention and every thread sees identical values. """ aper = CircularAperture(((150, 25), (90, 60)), 8) phot = AperturePhotometry(data, aper, error=np.ones_like(data)) def read(_): return {attr: np.asarray(getattr(phot, attr)) for attr in ('flux', 'flux_err', 'flags', 'id')} with ThreadPoolExecutor(max_workers=4) as executor: results = list(executor.map(read, range(16))) for result in results: for attr, values in result.items(): assert_allclose(values, results[0][attr]) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestRegionInput: def test_region_matches_aperture(self): from regions import CirclePixelRegion, PixCoord data = np.ones((40, 40), dtype=float) error = np.ones(data.shape, dtype=float) position = (20.0, 20.0) r = 10.0 region = CirclePixelRegion(PixCoord(*position), r) aper = CircularAperture(position, r) phot = AperturePhotometry(data, region, error=error) ref = AperturePhotometry(data, aper, error=error) assert_allclose(phot.flux, ref.flux) assert_allclose(phot.flux_err, ref.flux_err) def test_annulus_region(self): from regions import CircleAnnulusPixelRegion, PixCoord data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) region = CircleAnnulusPixelRegion(PixCoord(*position), 8.0, 10.0) aper = CircularAnnulus(position, 8.0, 10.0) phot = AperturePhotometry(data, region) ref = AperturePhotometry(data, aper) assert_allclose(phot.flux, ref.flux) class TestNThreads: """ Tests for the n_threads keyword. """ @staticmethod def make_inputs(): """ Build a deterministic image (with nonfinite values), error, mask, and positions (including off-edge positions). """ rng = np.random.default_rng(0) data = rng.normal(100.0, 5.0, (120, 130)) data[3, 3] = np.nan data[50, 50] = np.inf error = np.abs(rng.normal(5.0, 0.5, data.shape)) + 0.1 mask = np.zeros(data.shape, dtype=bool) mask[60:65, 85:90] = True positions = np.column_stack( [rng.uniform(-5, data.shape[1] + 5, 57), rng.uniform(-5, data.shape[0] + 5, 57)]) return data, error, mask, positions @pytest.mark.parametrize('n_threads', [2, 8]) def test_identical_results(self, n_threads): """ Test that multithreaded photometry gives results identical to the single-threaded computation, including for off-edge positions, masked pixels, and nonfinite data values. """ data, error, mask, positions = self.make_inputs() aper = CircularAperture(positions, r=7.0) phot1 = AperturePhotometry(data, aper, error=error, mask=mask) phot2 = AperturePhotometry(data, aper, error=error, mask=mask, n_threads=n_threads) assert phot2.n_threads == n_threads assert_equal(phot1.flux, phot2.flux) assert_equal(phot1.flux_err, phot2.flux_err) assert_equal(phot1.area, phot2.area) assert_equal(phot1.flags, phot2.flags) def test_more_threads_than_positions(self): """ Test that n_threads larger than the number of positions gives identical results. """ data = np.ones((40, 40)) aper = CircularAperture([(20, 20), (10, 10), (30, 25)], r=5.0) phot1 = AperturePhotometry(data, aper) phot2 = AperturePhotometry(data, aper, n_threads=8) assert_equal(phot1.flux, phot2.flux) assert_equal(phot1.flags, phot2.flags) def test_scalar_position(self): """ Test that a scalar aperture position with n_threads > 1 falls back to a single-chunk (serial) computation. """ data = np.ones((40, 40)) aper = CircularAperture((20, 20), r=5.0) phot1 = AperturePhotometry(data, aper) phot2 = AperturePhotometry(data, aper, n_threads=4) assert_equal(phot1.flux, phot2.flux) def test_aperture_list(self): """ Test multithreading with a list of input apertures. """ data, error, mask, positions = self.make_inputs() apers = [CircularAperture(positions, r=r) for r in (3.0, 7.0)] phot1 = AperturePhotometry(data, apers, error=error, mask=mask) phot2 = AperturePhotometry(data, apers, error=error, mask=mask, n_threads=4) assert_equal(phot1.flux, phot2.flux) assert_equal(phot1.flux_err, phot2.flux_err) assert_equal(phot1.area, phot2.area) assert_equal(phot1.flags, phot2.flags) def test_segmentation_masking(self): """ Test that per-source segmentation labels are chunked together with the positions. """ data, segm = make_scene() positions = [(21.0, 21.0), (28.0, 22.0), (21.0, 21.5), (28.5, 22.0), (20.5, 21.0)] labels = [1, 2, 1, 2, 1] aper = CircularAperture(positions, r=8.0) phot1 = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=labels, mask_method='mask') phot2 = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=labels, mask_method='mask', n_threads=3) assert_equal(phot1.flux, phot2.flux) assert_equal(phot1.area, phot2.area) assert_equal(phot1.flags, phot2.flags) def test_mask_based_fallback(self): """ Test that apertures that do not support the batch code path give correct results with n_threads > 1 (the mask-based code path stays serial). """ data, _, _, positions = self.make_inputs() aper = NoBatchCircularAperture(positions, r=7.0) ref = AperturePhotometry(data, CircularAperture(positions, r=7.0)) phot = AperturePhotometry(data, aper, n_threads=4) assert_allclose(phot.flux, ref.flux, equal_nan=True) def test_empty_positions(self): """ Test that an aperture with zero positions works with n_threads > 1 (zero chunks must fall back to the serial path). """ data = np.ones((11, 11)) aper = CircularAperture(np.empty((0, 2)), r=3.0) phot = AperturePhotometry(data, aper, n_threads=4) assert phot.n_positions == 0 assert phot.flux.shape == (0,) def test_invalid_n_threads(self): """ Test that an error is raised if n_threads is not a positive integer. """ data = np.ones((40, 40)) aper = CircularAperture((20, 20), r=5.0) match = 'n_threads must be a positive integer' for n_threads in (0, -1, 2.5, True): with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, n_threads=n_threads) def test_repr_and_meta(self): """ Test that n_threads appears in the repr and in the table metadata calling arguments. """ data = np.ones((40, 40)) aper = CircularAperture((20, 20), r=5.0) phot = AperturePhotometry(data, aper, n_threads=4) assert 'n_threads=4' in repr(phot) assert 'n_threads=4' in phot.to_table().meta[ 'aperture_photometry_args'] astropy-photutils-24be529/photutils/aperture/tests/test_photometry_flags.py000066400000000000000000000657011526452531300276440ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the aperture photometry quality flags. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from photutils.aperture import (APERTURE_FLAGS, AperturePhotometry, ApertureStats, CircularAnnulus, CircularAperture, EllipticalAperture, PolygonAperture, RectangularAperture, aperture_photometry) from photutils.aperture.flags import _counts_to_flag_bits from photutils.aperture.tests.conftest import (UNIT_SHAPE, NoBatchCircularAperture) from photutils.segmentation import SegmentationImage APERTURE_FACTORIES = [ lambda xy: CircularAperture(xy, r=3.0), lambda xy: CircularAnnulus(xy, r_in=2.0, r_out=4.0), lambda xy: EllipticalAperture(xy, a=4.0, b=2.5, theta=0.5), lambda xy: RectangularAperture(xy, w=5.0, h=4.0, theta=0.3), ] METHODS = [('exact', 5), ('center', 1), ('subpixel', 4)] def _flags(aperture, data, **kwargs): return AperturePhotometry(data, aperture, **kwargs).flags def _bbox_clipped_candidates(aper, shape): """ Per-source indicator of whether the aperture bounding box overlaps the data but is clipped by a data edge (the ``FLAG_COL_BBOX_CLIPPED`` candidate condition, computed from the aperture bounding boxes). """ ny, nx = shape candidates = [] for bbox in aper._bbox: overlap = (bbox.ixmin < nx and bbox.ixmax > 0 and bbox.iymin < ny and bbox.iymax > 0) clipped = (bbox.ixmin < 0 or bbox.iymin < 0 or bbox.ixmax > nx or bbox.iymax > ny) candidates.append(overlap and clipped) return np.array(candidates, dtype=bool) def _bruteforce_outside_weights(aper, shape, *, method, subpixels, candidates): """ Reference (nongated) per-source outside-weight test, built from explicit aperture masks. This replicates the mask-based branch of ``_resolve_outside_weights``, including its restriction to the candidate (bbox-clipped) sources. """ w_out = np.zeros(candidates.shape, dtype=bool) idx = np.flatnonzero(candidates) if idx.size == 0: return w_out sub = aper if aper.isscalar else aper[idx] masks = sub.to_mask(method=method, subpixels=subpixels) if sub.isscalar: masks = [masks] for i, mask in zip(idx, masks, strict=True): n_total = np.count_nonzero(mask.data) slc_large, slc_small = mask.get_overlap_slices(shape) if slc_large is None: w_out[i] = n_total > 0 else: n_in = np.count_nonzero(mask.data[slc_small]) w_out[i] = n_total > n_in return w_out class TestOverlapFlags: """ Tests for the no_overlap, partial_overlap, and no_pixels flags. """ def test_no_flags(self, unit_data): """ Test that a clean interior source has no flags set. """ data = unit_data aper = CircularAperture((12, 12), r=3.0) assert_array_equal(_flags(aper, data), [0]) @pytest.mark.parametrize('factory', APERTURE_FACTORIES) @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_no_overlap(self, unit_data, factory, method, subpixels): """ Test that fully off-image apertures set no_overlap and no_pixels. """ data = unit_data aper = factory([(-50.0, 12.0), (12.0, 12.0)]) result = AperturePhotometry(data, aper, method=method, subpixels=subpixels) expected = APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS assert result.flags[0] == expected assert np.isnan(result.flux[0]) assert result.flags[1] == 0 @pytest.mark.parametrize('factory', APERTURE_FACTORIES) @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_partial_overlap(self, unit_data, factory, method, subpixels): """ Test that apertures straddling an edge set partial_overlap. """ data = unit_data aper = factory([(0.0, 12.0), (12.0, 24.5), (12.0, 12.0)]) flags = _flags(aper, data, method=method, subpixels=subpixels) assert flags[0] == APERTURE_FLAGS.PARTIAL_OVERLAP assert flags[1] == APERTURE_FLAGS.PARTIAL_OVERLAP assert flags[2] == 0 def test_no_overlap_close_to_edge(self, unit_data): """ Test an aperture whose bounding box overlaps the data but whose nonzero-weight pixels are all outside (precise no_overlap). """ data = unit_data # bbox column 0 overlaps the data, but with the "center" method # the pixel center at x=0 (distance 1.2) is outside the circle aper = CircularAperture((-1.2, 12.0), r=0.8) result = AperturePhotometry(data, aper, method='center') assert result.flags == (APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS) # The results are undefined, the same as for an aperture whose # bounding box does not overlap the data assert np.isnan(result.flux) assert np.isnan(result.area) # With the exact method, the aperture area extends into the data result = AperturePhotometry(data, aper, method='exact') assert result.flags == APERTURE_FLAGS.PARTIAL_OVERLAP def test_clipped_bbox_only(self, unit_data): """ Test that a clipped bounding box alone does not set partial_overlap when all nonzero weights are inside the data. """ data = unit_data # bbox extends to column -1, but the pixel center at x=-1 # (distance 1.2 > r=0.8) is outside the circle aper = CircularAperture((0.2, 12.0), r=0.8) assert_array_equal(_flags(aper, data, method='center'), [0]) # The exact-method footprint does extend outside flags = _flags(aper, data, method='exact') assert_array_equal(flags, [APERTURE_FLAGS.PARTIAL_OVERLAP]) def test_no_pixels_tiny_aperture(self, unit_data): """ Test that a tiny aperture with the "center" method sets no_pixels. """ data = unit_data # Nearest pixel centers are sqrt(0.5) ~ 0.707 away aper = CircularAperture((12.5, 12.5), r=0.4) result = AperturePhotometry(data, aper, method='center') assert result.flags == APERTURE_FLAGS.NO_PIXELS assert result.flux == 0.0 # The exact method has a nonzero footprint assert_array_equal(_flags(aper, data, method='exact'), [0]) class TestPolygonOverlapFlags: """ A polygon whose vertices are not centered on its positions still has a tight bounding box, so the partial_overlap flag tracks the polygon itself rather than the box. """ @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_offcenter_inside(self, method, subpixels): """ Test that a polygon lying entirely inside the data is not flagged as partial_overlap. """ data = np.ones((12, 12)) # The centroid of this triangle is well off-center from the # vertices, so its bounding box is not centered on ``positions`` aper = PolygonAperture.from_vertices([(1.0, 1.0), (9.0, 1.0), (9.0, 9.0)]) # The tight bounding box lies entirely within the data bbox = aper.bbox assert bbox.ixmin >= 0 assert bbox.ixmax <= data.shape[1] assert bbox.iymin >= 0 assert bbox.iymax <= data.shape[0] result = AperturePhotometry(data, aper, method=method, subpixels=subpixels) assert result.flags == 0 stats = ApertureStats(data, aper, sum_method=method, subpixels=subpixels) assert stats.flags == 0 @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_offcenter_partial(self, method, subpixels): """ Test that a polygon extending past a data edge is still flagged as partial_overlap. """ data = np.ones((12, 12)) # The part of this triangle beyond the left data edge contains # whole pixels, so every mask method sees weight outside the data aper = PolygonAperture.from_vertices([(-3.0, 4.0), (9.0, 1.0), (9.0, 9.0)]) assert aper.bbox.ixmin < 0 result = AperturePhotometry(data, aper, method=method, subpixels=subpixels) assert result.flags == APERTURE_FLAGS.PARTIAL_OVERLAP stats = ApertureStats(data, aper, sum_method=method, subpixels=subpixels) assert stats.flags == APERTURE_FLAGS.PARTIAL_OVERLAP class TestMaskedAndNonFiniteFlags: """ Tests for the masked_pixels, all_masked, and nonfinite flags. """ @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_masked_pixels(self, unit_data, method, subpixels): """ Test the masked_pixels and all_masked flags. """ data = unit_data # One masked pixel inside the aperture mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[12, 12] = True aper = CircularAperture((12, 12), r=3.0) flags = _flags(aper, data, mask=mask, method=method, subpixels=subpixels) assert flags == APERTURE_FLAGS.MASKED_PIXELS # Masked pixel inside the bounding box but outside the aperture mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[9, 9] = True flags = _flags(aper, data, mask=mask, method=method, subpixels=subpixels) assert flags == 0 # Fully masked aperture mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[8:17, 8:17] = True flags = _flags(aper, data, mask=mask, method=method, subpixels=subpixels) assert flags == (APERTURE_FLAGS.MASKED_PIXELS | APERTURE_FLAGS.ALL_MASKED) def test_non_finite_data(self, unit_mask): """ Test the non_finite_data flag. In photometry, unmasked nonfinite values contribute to the sum (making it NaN). They do not set all_masked. This tests the legacy (nonclass) engine call directly (``mask_nonfinite=False``, the default), which corrupts the sum with the nonfinite value instead of masking it out, unlike `AperturePhotometry`. """ data = np.ones(UNIT_SHAPE) data[12, 12] = np.nan aper = CircularAperture((12, 12), r=3.0) result = aper._photometry(data) assert result.flags[0] == APERTURE_FLAGS.NON_FINITE_DATA assert np.isnan(result.flux[0]) # An all-NaN aperture is still non_finite_data only (the pixels # contribute, so all_masked is not set) data = np.full(UNIT_SHAPE, np.nan) result = aper._photometry(data) assert result.flags[0] == APERTURE_FLAGS.NON_FINITE_DATA # A masked nonfinite pixel counts only as masked data = np.ones(UNIT_SHAPE) data[12, 12] = np.nan mask = unit_mask mask[12, 12] = True flags = _flags(aper, data, mask=mask) assert flags == APERTURE_FLAGS.MASKED_PIXELS def test_non_finite_error(self, unit_data): """ Test the non_finite_error flag. """ data = unit_data error = np.ones(UNIT_SHAPE) error[12, 12] = np.inf aper = CircularAperture((12, 12), r=3.0) flags = _flags(aper, data, error=error) assert flags == APERTURE_FLAGS.NON_FINITE_ERROR # Without an error array, the flag is never set assert_array_equal(_flags(aper, data), [0]) class TestSegmentationFlags: """ Tests for the neighbor_pixels and uncorrected_pixels flags. """ @pytest.mark.parametrize('mask_method', ['mask', 'source_only', 'correct']) def test_neighbor_pixels(self, unit_data, mask_method): """ Test the neighbor_pixels flag for all segmentation mask methods. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=int) segm[10:15, 10:15] = 1 segm[12, 14] = 2 # neighbor pixel inside the aperture aper = CircularAperture((12, 12), r=3.0) flags = _flags(aper, data, segmentation_image=SegmentationImage(segm), labels=1, mask_method=mask_method) assert flags == APERTURE_FLAGS.NEIGHBOR_PIXELS # Without any neighbor pixels inside the aperture, no flag is set segm2 = np.zeros(UNIT_SHAPE, dtype=int) segm2[10:15, 10:15] = 1 flags = _flags(aper, data, segmentation_image=SegmentationImage(segm2), labels=1, mask_method=mask_method) assert flags == 0 def test_background_only_neighbor_pixels(self, unit_data): """ Test the neighbor_pixels flag with mask_method='background_only', where every labeled pixel counts as a neighbor pixel. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=int) segm[11:14, 11:14] = 1 # a source inside the aperture aper = CircularAperture((12, 12), r=3.0) flags = _flags(aper, data, segmentation_image=SegmentationImage(segm), mask_method='background_only') assert flags == APERTURE_FLAGS.NEIGHBOR_PIXELS # An aperture on pure background is not flagged aper = CircularAperture((4, 4), r=3.0) flags = _flags(aper, data, segmentation_image=SegmentationImage(segm), mask_method='background_only') assert flags == 0 def test_uncorrected_pixels(self, unit_data): """ Test the uncorrected_pixels flag with mask_method='correct'. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=int) segm[10:15, 10:15] = 1 segm[12, 14] = 2 # neighbor whose mirror (12, 10) is a source pixel segm[12, 10] = 2 # make the mirror a neighbor too: uncorrectable aper = CircularAperture((12, 12), r=3.0) flags = _flags(aper, data, segmentation_image=SegmentationImage(segm), labels=1, mask_method='correct') assert flags == (APERTURE_FLAGS.NEIGHBOR_PIXELS | APERTURE_FLAGS.UNCORRECTED_PIXELS) # A correctable neighbor does not set uncorrected_pixels segm[12, 10] = 0 flags = _flags(aper, data, segmentation_image=SegmentationImage(segm), labels=1, mask_method='correct') assert flags == APERTURE_FLAGS.NEIGHBOR_PIXELS class TestMaskPathParity: """ The mask-based code path must set the same flags as the batch driver. """ @pytest.mark.parametrize(('method', 'subpixels'), METHODS) def test_basic(self, unit_mask, method, subpixels): """ Test that the mask-based code path produces flags identical to the batch Cython driver. """ rng = np.random.default_rng(0) data = rng.normal(1.0, 0.1, size=UNIT_SHAPE) data[13, 12] = np.nan error = np.ones(UNIT_SHAPE) error[10, 12] = np.inf mask = unit_mask mask[12, 12] = True xy = [(12.0, 12.0), (0.0, 12.0), (-50.0, 12.0), (24.5, 24.5), (0.2, 12.0), (-1.2, 12.0)] batch_aper = CircularAperture(xy, r=3.0) nobatch_aper = NoBatchCircularAperture(xy, r=3.0) kwargs = {'error': error, 'mask': mask, 'method': method, 'subpixels': subpixels} result_batch = batch_aper._photometry(data, **kwargs) result_nobatch = nobatch_aper._photometry(data, **kwargs) assert_array_equal(result_batch.flags, result_nobatch.flags) assert_allclose(result_batch.flux, result_nobatch.flux, rtol=1e-12, equal_nan=True) def test_segmentation(self, unit_data): """ Test batch/mask-path flag parity with segmentation masking. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=int) segm[10:15, 10:15] = 1 segm[12, 14] = 2 segm[12, 10] = 2 segm[3:7, 3:7] = 3 xy = [(12.0, 12.0), (5.0, 5.0)] labels = [1, 3] segm = SegmentationImage(segm) for mask_method in ('mask', 'source_only', 'background_only', 'correct'): flags_batch = CircularAperture(xy, r=3.0)._photometry( data, segmentation_image=segm, labels=labels, mask_method=mask_method).flags flags_nobatch = NoBatchCircularAperture(xy, r=3.0)._photometry( data, segmentation_image=segm, labels=labels, mask_method=mask_method).flags assert_array_equal(flags_batch, flags_nobatch) @pytest.mark.parametrize('mask_method', ['mask', 'source_only', 'correct']) @pytest.mark.parametrize('nan_pixel', [(12, 14), (12, 10)], ids=['nan_neighbor', 'nan_mirror']) def test_nonfinite_segmentation(self, unit_data, mask_method, nan_pixel): """ Test batch/mask-path parity when nonfinite masking (the AperturePhotometry path) combines with segmentation masking. Nonfinite pixels must be masked before the segmentation handling, so a NaN neighbor pixel is excluded as nonfinite (not counted as a neighbor pixel or mirror-corrected) and a NaN mirror pixel makes its neighbor uncorrectable with mask_method='correct', matching the batch kernel and ApertureStats. """ data = unit_data data[nan_pixel] = np.nan segm = np.zeros(UNIT_SHAPE, dtype=int) segm[10:15, 10:15] = 1 segm[12, 14] = 2 # neighbor pixel whose mirror is (12, 10) kwargs = {'segmentation_image': SegmentationImage(segm), 'labels': [1], 'mask_method': mask_method} batch_aper = CircularAperture((12.0, 12.0), r=3.0) nobatch_aper = NoBatchCircularAperture((12.0, 12.0), r=3.0) result_batch = AperturePhotometry(data, batch_aper, **kwargs) result_nobatch = AperturePhotometry(data, nobatch_aper, **kwargs) assert_array_equal(result_batch.flags, result_nobatch.flags) assert_allclose(result_batch.flux, result_nobatch.flux, rtol=1e-12) assert_allclose(result_batch.area.value, result_nobatch.area.value, rtol=1e-12) # The NaN pixel is excluded as non_finite_data in both paths assert result_batch.flags & APERTURE_FLAGS.NON_FINITE_DATA stats = ApertureStats(data, batch_aper, **kwargs) assert result_batch.flags == stats.flags assert_allclose(result_batch.flux, stats.sum, rtol=1e-12) # The batch gather kernel (the "center" statistics) must test # finiteness per pixel, including at the mirror pixel of a # corrected neighbor pixel, matching the mask-based path. stats_nobatch = ApertureStats(data, nobatch_aper, **kwargs) assert stats._fast_gather is not None assert stats_nobatch._fast_gather is None for prop in ('mean', 'median', 'center_aper_area'): assert_allclose(getattr(stats, prop), getattr(stats_nobatch, prop), rtol=1e-12, err_msg=prop) @pytest.mark.parametrize('nan_pixel', [(12, 13), (12, 10)], ids=['nan_source', 'nan_background']) def test_nonfinite_background_only(self, unit_data, nan_pixel): """ Test batch/mask-path parity when nonfinite masking combines with mask_method='background_only'. A NaN source pixel is excluded as nonfinite (not counted as a neighbor pixel) and a NaN background pixel is excluded as nonfinite, matching the batch kernel and ApertureStats. """ data = unit_data data[nan_pixel] = np.nan segm = np.zeros(UNIT_SHAPE, dtype=int) segm[11:14, 11:14] = 1 # a source inside the aperture kwargs = {'segmentation_image': SegmentationImage(segm), 'mask_method': 'background_only'} batch_aper = CircularAperture((12.0, 12.0), r=3.0) nobatch_aper = NoBatchCircularAperture((12.0, 12.0), r=3.0) result_batch = AperturePhotometry(data, batch_aper, **kwargs) result_nobatch = AperturePhotometry(data, nobatch_aper, **kwargs) assert_array_equal(result_batch.flags, result_nobatch.flags) assert_allclose(result_batch.flux, result_nobatch.flux, rtol=1e-12) assert_allclose(result_batch.area.value, result_nobatch.area.value, rtol=1e-12) expected = (APERTURE_FLAGS.NON_FINITE_DATA | APERTURE_FLAGS.NEIGHBOR_PIXELS) assert result_batch.flags == expected stats = ApertureStats(data, batch_aper, **kwargs) assert result_batch.flags == stats.flags assert_allclose(result_batch.flux, stats.sum, rtol=1e-12) class TestFlagsAPI: """ Tests for how the flags are exposed by the photometry classes. """ def test_flag_combinations(self, unit_data, unit_mask): """ Test that multiple conditions combine bitwise. """ data = unit_data data[12, 6] = np.nan # outside the r=3 aperture at (0, 12) mask = unit_mask mask[10, 1] = True aper = CircularAperture((0.0, 12.0), r=3.0) # straddles left edge flags = _flags(aper, data, mask=mask) assert flags == (APERTURE_FLAGS.PARTIAL_OVERLAP | APERTURE_FLAGS.MASKED_PIXELS) def test_flags_multiple_apertures(self, unit_data): """ Test the AperturePhotometry flags for multiple positions and a list of apertures (2D flags). """ data = unit_data xy = [(12.0, 12.0), (-50.0, 12.0), (0.0, 12.0)] aper = CircularAperture(xy, r=3.0) phot = AperturePhotometry(data, aper) expected = [0, APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS, APERTURE_FLAGS.PARTIAL_OVERLAP] assert_array_equal(phot.flags, expected) # The flags are multidimensional for multiple apertures aper2 = CircularAperture(xy, r=4.0) phot = AperturePhotometry(data, [aper, aper2]) assert_array_equal(phot.flags[:, 0], expected) assert_array_equal(phot.flags[:, 1], expected) def test_legacy_function_has_no_flags(self, unit_data): """ Test that the legacy aperture_photometry function output table contains no flags or area columns. """ xy = [(12.0, 12.0), (0.0, 12.0)] aper = CircularAperture(xy, r=3.0) tbl = aperture_photometry(unit_data, aper) assert tbl.colnames == ['id', 'x_center', 'y_center', 'aperture_sum'] def test_flags_with_units(self): """ Test that flags are also set for Quantity inputs. """ data = np.ones(UNIT_SHAPE) * u.Jy data[12, 12] = np.nan * u.Jy aper = CircularAperture((12, 12), r=3.0) phot = AperturePhotometry(data, aper) assert phot.flags == APERTURE_FLAGS.NON_FINITE_DATA def test_scalar_flag_counts(self): """ Test that _counts_to_flag_bits accepts 1D count rows and scalar overlap/w_out values. """ counts = np.zeros(8, dtype=int) flags = _counts_to_flag_bits(counts, np.False_, np.False_) assert flags[0] == (APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS) class TestResolveOutsideWeights: """ Tests for the precise outside-weight test used by the partial_overlap flag. """ def test_no_mask_overlap(self): """ Test that _resolve_outside_weights returns True for a candidate whose aperture mask does not actually overlap the data. In practice, the caller only ever marks a source as a candidate when its aperture mask does overlap the data, so the mask's bounding box is never actually clipped away entirely. This directly exercises that defensive branch. A non-'exact' method is used because the 'exact' method takes the gated fast path (see ``_resolve_outside_weights``). """ aper = CircularAperture((-5.0, 12.0), r=3.0) shape = (25, 25) mask = aper.to_mask(method='center') assert mask.get_overlap_slices(shape) == (None, None) candidates = np.array([True]) w_out = aper._resolve_outside_weights(shape, method='center', subpixels=5, candidates=candidates) assert w_out[0] @pytest.mark.parametrize('factory', APERTURE_FACTORIES) def test_exact_gating(self, factory): """ Test that the gated 'exact' fast path in _resolve_outside_weights matches the explicit per-source mask computation. For the 'exact' method the minimal bounding box is tight, so a bbox that is clipped by a data edge always leaves a positive-area aperture sliver outside the data. The gated path returns the bbox-clipped candidates unchanged. This must equal the reference mask-based outside-weight test for a mix of interior, edge, corner, and fully off-image positions. """ ny, nx = UNIT_SHAPE xy = [(12.0, 12.0), # interior (not a candidate) (0.0, 12.0), (nx - 1.0, 12.0), # left/right edges (12.0, 0.0), (12.0, ny - 1.0), # bottom/top edges (0.0, 0.0), (nx - 1.0, ny - 1.0), # corners (0.0, ny - 1.0), (nx - 1.0, 0.0), # corners (1.5, 12.0), (12.0, ny - 2.0), # near edges (-50.0, 12.0)] # fully off-image (not a candidate) aper = factory(xy) candidates = _bbox_clipped_candidates(aper, UNIT_SHAPE) gated = aper._resolve_outside_weights(UNIT_SHAPE, method='exact', subpixels=5, candidates=candidates) brute = _bruteforce_outside_weights(aper, UNIT_SHAPE, method='exact', subpixels=5, candidates=candidates) # The gated path returns the candidates unchanged. assert_array_equal(gated, candidates) # That exactly matches the explicit mask-based computation. assert_array_equal(gated, brute) def test_exact_copy(self): """ Test that the gated 'exact' path returns a copy, not the input candidates array. """ aper = CircularAperture([(0.0, 12.0), (12.0, 12.0)], r=3.0) candidates = _bbox_clipped_candidates(aper, UNIT_SHAPE) w_out = aper._resolve_outside_weights(UNIT_SHAPE, method='exact', subpixels=5, candidates=candidates) assert w_out is not candidates assert_array_equal(w_out, candidates) astropy-photutils-24be529/photutils/aperture/tests/test_pixel_sky_conversions.py000066400000000000000000001364201526452531300307120ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for aperture pixel-to-sky and sky-to-pixel coordinate conversions. This module covers: * round-trip conversions for all pixel and sky aperture classes through simple, rotated, distorted (SIP), sheared, and generalized (`gwcs.wcs.WCS`) WCS objects, exercising the SVD/Jacobian shape conversion (the SIP and gWCS cases in particular force the distortion (Jacobian) code path of the internal ``_wcs_helpers``) * the rotation-angle convention for directed (elliptical and rectangular) apertures, which pins down the 90 deg offset between the sky position angle (measured from North) and the pixel angle (measured from the positive x-axis). """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.io.fits import Header 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.datasets import make_gwcs from photutils.utils._optional_deps import HAS_GWCS from photutils.utils.tests.wcs_test_helpers import CountingWCS # Module constants CENTER = SkyCoord(100 * u.deg, 30 * u.deg) PIX_CENTER = (10.5, 10.5) # Position angles (deg) used to pin the sky/pixel angle convention. ANGLES_DEG = [0, 30, 60, 135, 200, 315] # Position angles (deg) used for the sheared-WCS and degenerate-shape # round-trip tests. SHEAR_ANGLES_DEG = [0.0, 30.0, 75.0] # Each case bundles a matching (sky, pixel) aperture class pair with # their construction kwargs. ``size_attrs`` lists the shape attributes # compared in round-trip assertions and ``has_angle`` flags whether the # aperture carries a ``theta`` rotation angle. _APERTURE_CASES = [ {'id': 'ellipse', 'sky_cls': SkyEllipticalAperture, 'sky_kw': {'a': 2 * u.arcsec, 'b': 1 * u.arcsec, 'theta': 30 * u.deg}, 'pix_cls': EllipticalAperture, 'pix_kw': {'a': 4, 'b': 2, 'theta': 45 * u.deg}, 'size_attrs': ('a', 'b'), 'has_angle': True}, {'id': 'rectangle', 'sky_cls': SkyRectangularAperture, 'sky_kw': {'w': 2 * u.arcsec, 'h': 1 * u.arcsec, 'theta': 30 * u.deg}, 'pix_cls': RectangularAperture, 'pix_kw': {'w': 4, 'h': 2, 'theta': 45 * u.deg}, 'size_attrs': ('w', 'h'), 'has_angle': True}, {'id': 'ellipse_annulus', 'sky_cls': SkyEllipticalAnnulus, 'sky_kw': {'a_in': 1 * u.arcsec, 'a_out': 2 * u.arcsec, 'b_out': 1 * u.arcsec, 'theta': 30 * u.deg}, 'pix_cls': EllipticalAnnulus, 'pix_kw': {'a_in': 2, 'a_out': 4, 'b_out': 2, 'theta': 45 * u.deg}, 'size_attrs': ('a_in', 'a_out', 'b_out'), 'has_angle': True}, {'id': 'rectangle_annulus', 'sky_cls': SkyRectangularAnnulus, 'sky_kw': {'w_in': 1 * u.arcsec, 'w_out': 2 * u.arcsec, 'h_out': 1 * u.arcsec, 'theta': 30 * u.deg}, 'pix_cls': RectangularAnnulus, 'pix_kw': {'w_in': 2, 'w_out': 4, 'h_out': 2, 'theta': 45 * u.deg}, 'size_attrs': ('w_in', 'w_out', 'h_out'), 'has_angle': True}, {'id': 'circle', 'sky_cls': SkyCircularAperture, 'sky_kw': {'r': 2 * u.arcsec}, 'pix_cls': CircularAperture, 'pix_kw': {'r': 4}, 'size_attrs': ('r',), 'has_angle': False}, {'id': 'circle_annulus', 'sky_cls': SkyCircularAnnulus, 'sky_kw': {'r_in': 1 * u.arcsec, 'r_out': 2 * u.arcsec}, 'pix_cls': CircularAnnulus, 'pix_kw': {'r_in': 2, 'r_out': 4}, 'size_attrs': ('r_in', 'r_out'), 'has_angle': False}, ] # All aperture cases and the directed (rotatable) subset, wrapped as # pytest params with readable ids. APERTURE_CASES = [pytest.param(case, id=case['id']) for case in _APERTURE_CASES] DIRECTED_CASES = [pytest.param(case, id=case['id']) for case in _APERTURE_CASES if case['has_angle']] def _angle_diff_deg(actual, desired): """ Signed angular difference in degrees, wrapped to (-180, 180]. """ diff = (actual.to_value(u.deg) - desired.to_value(u.deg) + 180) % 360 return diff - 180 def _build_pair(case, theta): """ Build a matching (sky_aperture, pixel_aperture) pair from a case, overriding the rotation angle ``theta`` of both apertures. """ sky = case['sky_cls'](CENTER, **{**case['sky_kw'], 'theta': theta}) pix = case['pix_cls'](PIX_CENTER, **{**case['pix_kw'], 'theta': theta}) return sky, pix def _make_sip_wcs(*, ra_deg=100.0, dec_deg=30.0): """ Build a small TAN-SIP WCS centered at the given (RA, Dec). The SIP terms are tiny but nonzero, which forces the Jacobian (distortion) code path to be exercised by ``to_pixel``/``to_sky``. """ header = Header() header['NAXIS'] = 2 header['NAXIS1'] = 21 header['NAXIS2'] = 21 header['CRPIX1'] = 10.5 header['CRPIX2'] = 10.5 header['CRVAL1'] = ra_deg header['CRVAL2'] = dec_deg header['CTYPE1'] = 'RA---TAN-SIP' header['CTYPE2'] = 'DEC--TAN-SIP' cdelt = 0.1 / 3600.0 header['CD1_1'] = -cdelt header['CD1_2'] = 0.0 header['CD2_1'] = 0.0 header['CD2_2'] = cdelt header['A_ORDER'] = 2 header['A_2_0'] = 1e-7 header['A_0_2'] = 1e-7 header['B_ORDER'] = 2 header['B_2_0'] = 1e-7 header['B_0_2'] = 1e-7 return WCS(header) @pytest.fixture def simple_wcs(): """ A simple axis-aligned TAN WCS (RA increases to the left, equal pixel scales, North = +y). """ wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [CENTER.ra.deg, CENTER.dec.deg] wcs.wcs.cdelt = [-0.1 / 3600, 0.1 / 3600] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs @pytest.fixture def rotated_wcs(): """ A 25 deg rotated TAN WCS. """ rotation_deg = 25.0 cdelt = 0.1 / 3600 rad = np.radians(rotation_deg) cos_a = np.cos(rad) sin_a = np.sin(rad) wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [CENTER.ra.deg, CENTER.dec.deg] wcs.wcs.cd = [[-cdelt * cos_a, cdelt * sin_a], [cdelt * sin_a, cdelt * cos_a]] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs @pytest.fixture def sheared_wcs(): """ A sheared TAN WCS where the pixel x- and y-axes are 80 deg apart on the sky (10 deg of shear from a perpendicular axis-aligned grid). """ cdelt = 0.1 / 3600 a = np.radians(80.0) wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [CENTER.ra.deg, CENTER.dec.deg] wcs.wcs.cd = [[-cdelt, cdelt * np.cos(a)], [0.0, cdelt * np.sin(a)]] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs @pytest.fixture def sip_wcs(): """ A TAN-SIP WCS with small distortion terms centered at CENTER. """ return _make_sip_wcs() @pytest.fixture def flipped_wcs(): """ A flipped-parity TAN WCS (North down, East left). Both CDELT values are negative, so the pixel scale matrix has a positive determinant (parity = +1), opposite the standard astronomical convention. North (increasing Dec) points along -y and East (increasing RA) points along -x. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [CENTER.ra.deg, CENTER.dec.deg] wcs.wcs.cdelt = [-0.1 / 3600, -0.1 / 3600] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs @pytest.fixture def galactic_wcs(): """ A simple axis-aligned TAN WCS in Galactic coordinates centered on CENTER. """ center = CENTER.galactic wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [center.l.deg, center.b.deg] wcs.wcs.cdelt = [-0.1 / 3600, 0.1 / 3600] wcs.wcs.ctype = ['GLON-TAN', 'GLAT-TAN'] return wcs @pytest.fixture def swapped_wcs(): """ A TAN WCS with the latitude axis first (Dec, RA). The CD matrix maps the same sky footprint as ``simple_wcs``, so every conversion must give the same aperture. """ cdelt = 0.1 / 3600 wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [CENTER.dec.deg, CENTER.ra.deg] wcs.wcs.cd = [[0.0, cdelt], [-cdelt, 0.0]] wcs.wcs.ctype = ['DEC--TAN', 'RA---TAN'] return wcs class TestSkyToPixel: """ Converting a sky aperture to a pixel aperture must return the matching pixel class with positive shape parameters. """ @pytest.mark.parametrize('case', APERTURE_CASES) def test_simple_wcs(self, simple_wcs, case): pix = case['sky_cls'](CENTER, **case['sky_kw']).to_pixel( simple_wcs) assert isinstance(pix, case['pix_cls']) for attr in case['size_attrs']: assert getattr(pix, attr) > 0 @pytest.mark.parametrize('case', APERTURE_CASES) def test_rotated_wcs(self, rotated_wcs, case): pix = case['sky_cls'](CENTER, **case['sky_kw']).to_pixel(rotated_wcs) assert isinstance(pix, case['pix_cls']) for attr in case['size_attrs']: assert getattr(pix, attr) > 0 @pytest.mark.parametrize('case', APERTURE_CASES) def test_sip_wcs(self, sip_wcs, case): """ The Jacobian (distortion) path is used automatically for a SIP WCS. """ pix = case['sky_cls'](CENTER, **case['sky_kw']).to_pixel(sip_wcs) assert isinstance(pix, case['pix_cls']) for attr in case['size_attrs']: assert getattr(pix, attr) > 0 class TestPixelToSky: """ Converting a pixel aperture to a sky aperture must return the matching sky class with positive angular shape parameters. """ @pytest.mark.parametrize('case', APERTURE_CASES) def test_simple_wcs(self, simple_wcs, case): sky = case['pix_cls'](PIX_CENTER, **case['pix_kw']).to_sky( simple_wcs) assert isinstance(sky, case['sky_cls']) for attr in case['size_attrs']: assert getattr(sky, attr) > 0 * u.arcsec @pytest.mark.parametrize('case', APERTURE_CASES) def test_sip_wcs(self, sip_wcs, case): sky = case['pix_cls'](PIX_CENTER, **case['pix_kw']).to_sky(sip_wcs) assert isinstance(sky, case['sky_cls']) for attr in case['size_attrs']: assert getattr(sky, attr) > 0 * u.arcsec class TestRoundTripSkyPixelSky: """ A sky -> pixel -> sky round-trip must recover the original shape parameters and rotation angle. """ @pytest.mark.parametrize('case', APERTURE_CASES) def test_simple_wcs(self, simple_wcs, case): sky = case['sky_cls'](CENTER, **case['sky_kw']) sky_rt = sky.to_pixel(simple_wcs).to_sky(simple_wcs) assert isinstance(sky_rt, case['sky_cls']) for attr in case['size_attrs']: assert_quantity_allclose(getattr(sky_rt, attr), getattr(sky, attr)) if case['has_angle']: assert_quantity_allclose(sky_rt.theta, sky.theta, atol=1e-9 * u.deg) @pytest.mark.parametrize('case', APERTURE_CASES) def test_rotated_wcs(self, rotated_wcs, case): sky = case['sky_cls'](CENTER, **case['sky_kw']) sky_rt = sky.to_pixel(rotated_wcs).to_sky(rotated_wcs) for attr in case['size_attrs']: assert_quantity_allclose(getattr(sky_rt, attr), getattr(sky, attr)) if case['has_angle']: assert_quantity_allclose(sky_rt.theta, sky.theta, atol=1e-9 * u.deg) @pytest.mark.parametrize('case', APERTURE_CASES) def test_sip_wcs(self, sip_wcs, case): """ Round-trip through a distorted (SIP) WCS, exercising the Jacobian code path. """ sky = case['sky_cls'](CENTER, **case['sky_kw']) sky_rt = sky.to_pixel(sip_wcs).to_sky(sip_wcs) for attr in case['size_attrs']: assert_quantity_allclose(getattr(sky_rt, attr), getattr(sky, attr), rtol=1e-3) if case['has_angle']: assert_quantity_allclose(sky_rt.theta, sky.theta, atol=1e-3 * u.deg) class TestRoundTripPixelSkyPixel: """ A pixel -> sky -> pixel round-trip must recover the original center, shape parameters, and rotation angle. """ @pytest.mark.parametrize('case', APERTURE_CASES) def test_simple_wcs(self, simple_wcs, case): pix = case['pix_cls'](PIX_CENTER, **case['pix_kw']) pix_rt = pix.to_sky(simple_wcs).to_pixel(simple_wcs) assert_allclose(pix_rt.positions, pix.positions) for attr in case['size_attrs']: assert_allclose(getattr(pix_rt, attr), getattr(pix, attr)) if case['has_angle']: assert_quantity_allclose(pix_rt.theta, pix.theta, atol=1e-9 * u.deg) @pytest.mark.parametrize('case', APERTURE_CASES) def test_rotated_wcs(self, rotated_wcs, case): pix = case['pix_cls'](PIX_CENTER, **case['pix_kw']) pix_rt = pix.to_sky(rotated_wcs).to_pixel(rotated_wcs) assert_allclose(pix_rt.positions, pix.positions) for attr in case['size_attrs']: assert_allclose(getattr(pix_rt, attr), getattr(pix, attr)) if case['has_angle']: assert_quantity_allclose(pix_rt.theta, pix.theta, atol=1e-9 * u.deg) @pytest.mark.parametrize('case', APERTURE_CASES) def test_sip_wcs(self, sip_wcs, case): pix = case['pix_cls'](PIX_CENTER, **case['pix_kw']) pix_rt = pix.to_sky(sip_wcs).to_pixel(sip_wcs) assert_allclose(pix_rt.positions, pix.positions, atol=1e-6) for attr in case['size_attrs']: assert_allclose(getattr(pix_rt, attr), getattr(pix, attr), rtol=1e-3) if case['has_angle']: assert_quantity_allclose(pix_rt.theta, pix.theta, atol=1e-3 * u.deg) class TestSwappedAxisWCS: """ A WCS with the latitude axis first must give the same apertures as the equivalent WCS with the longitude axis first. A round-trip alone would not catch a Jacobian built from swapped longitude and latitude, because the forward and inverse conversions would share the same error. """ @pytest.mark.parametrize('case', APERTURE_CASES) def test_sky_to_pixel(self, simple_wcs, swapped_wcs, case): sky = case['sky_cls'](CENTER, **case['sky_kw']) expected = sky.to_pixel(simple_wcs) pix = sky.to_pixel(swapped_wcs) assert_allclose(pix.positions, expected.positions, atol=1e-8) for attr in case['size_attrs']: assert_allclose(getattr(pix, attr), getattr(expected, attr), rtol=1e-9) if case['has_angle']: assert_quantity_allclose(pix.theta, expected.theta, atol=1e-6 * u.deg) @pytest.mark.parametrize('case', APERTURE_CASES) def test_pixel_to_sky(self, simple_wcs, swapped_wcs, case): pix = case['pix_cls'](PIX_CENTER, **case['pix_kw']) expected = pix.to_sky(simple_wcs) sky = pix.to_sky(swapped_wcs) assert sky.positions.separation(expected.positions).arcsec < 1e-8 for attr in case['size_attrs']: assert_quantity_allclose(getattr(sky, attr), getattr(expected, attr), rtol=1e-9) if case['has_angle']: assert_quantity_allclose(sky.theta, expected.theta, atol=1e-6 * u.deg) @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') class TestGWCSRoundTrip: """ Round-trip conversions through a generalized WCS (`gwcs.wcs.WCS`). A gWCS has no ``has_distortion`` attribute, so the Jacobian path is always used. """ @pytest.mark.parametrize('case', APERTURE_CASES) def test_sky_pixel_sky(self, case): wcs = make_gwcs((100, 100)) center = wcs.pixel_to_world(50, 50) sky = case['sky_cls'](center, **case['sky_kw']) sky_rt = sky.to_pixel(wcs).to_sky(wcs) assert isinstance(sky_rt, case['sky_cls']) for attr in case['size_attrs']: assert_quantity_allclose(getattr(sky_rt, attr), getattr(sky, attr), rtol=1e-5) if case['has_angle']: assert_quantity_allclose(sky_rt.theta, sky.theta, atol=1e-4 * u.deg) @pytest.mark.parametrize('case', APERTURE_CASES) def test_pixel_sky_pixel(self, case): wcs = make_gwcs((100, 100)) pix = case['pix_cls']((50, 50), **case['pix_kw']) pix_rt = pix.to_sky(wcs).to_pixel(wcs) assert_allclose(pix_rt.positions, pix.positions, rtol=1e-5) for attr in case['size_attrs']: assert_allclose(getattr(pix_rt, attr), getattr(pix, attr), rtol=1e-5) if case['has_angle']: assert_quantity_allclose(pix_rt.theta, pix.theta, atol=1e-4 * u.deg) class TestSkyPixelAngleConvention: """ Verify the sky/pixel angle conventions on an axis-aligned WCS where North = +y. For a directed sky aperture, ``theta`` is the position angle (PA) of the width axis measured from North, counterclockwise. For a directed pixel aperture, ``theta`` is measured counterclockwise from the positive x-axis. For a standard axis-aligned WCS (RA increasing to the left, equal isotropic pixel scale, North = +y), these two conventions differ by exactly 90 deg: sky.to_pixel(wcs).theta == sky.theta + 90 deg (mod 360 deg) pixel.to_sky(wcs).theta == pixel.theta - 90 deg (mod 360 deg) These tests pin that convention down so that any future refactor of the internal ``_wcs_helpers`` cannot silently reintroduce a symmetric 90 deg offset bug, which is invisible to round-trip-only tests because the offset cancels in sky -> pixel -> sky (and vice versa). """ @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', ANGLES_DEG) def test_sky_to_pixel_theta(self, simple_wcs, case, theta_deg): """ Verify ``sky.to_pixel`` returns ``theta + 90 deg`` (mod 360). """ sky, _ = _build_pair(case, theta_deg * u.deg) pix = sky.to_pixel(simple_wcs) diff = _angle_diff_deg(pix.theta, (theta_deg + 90) * u.deg) assert abs(diff) < 2e-5 @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', ANGLES_DEG) def test_pixel_to_sky_theta(self, simple_wcs, case, theta_deg): """ Verify ``pixel.to_sky`` returns ``theta - 90 deg`` (mod 360). """ _, pix = _build_pair(case, theta_deg * u.deg) sky = pix.to_sky(simple_wcs) diff = _angle_diff_deg(sky.theta, (theta_deg - 90) * u.deg) assert abs(diff) < 2e-5 def test_north_is_plus_y(self, simple_wcs): """ Test that a sky aperture at PA=0 (North) maps to a pixel aperture pointing along +y, i.e., pixel theta = 90 deg. """ sky = SkyEllipticalAperture(CENTER, a=2 * u.arcsec, b=1 * u.arcsec, theta=0 * u.deg) pix = sky.to_pixel(simple_wcs) diff = _angle_diff_deg(pix.theta, 90 * u.deg) assert abs(diff) < 2e-5 def test_east_is_minus_x(self, simple_wcs): """ Test that a sky aperture at PA=90 deg (East) maps to a pixel aperture pointing along -x, i.e., pixel theta = 180 deg. """ sky = SkyEllipticalAperture(CENTER, a=2 * u.arcsec, b=1 * u.arcsec, theta=90 * u.deg) pix = sky.to_pixel(simple_wcs) diff = _angle_diff_deg(pix.theta, 180 * u.deg) assert abs(diff) < 2e-5 class TestShearedWCSRoundTrip: """ Verify that directed apertures round-trip exactly through a sheared WCS, where the pixel x- and y-axes are not perpendicular on the sky. """ @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', SHEAR_ANGLES_DEG) def test_sky_pixel_sky(self, sheared_wcs, case, theta_deg): sky, _ = _build_pair(case, theta_deg * u.deg) sky_rt = sky.to_pixel(sheared_wcs).to_sky(sheared_wcs) diff = _angle_diff_deg(sky_rt.theta, theta_deg * u.deg) assert abs(diff) < 1e-6 # Shape parameters must round-trip too. for attr in ('a', 'b', 'w', 'h', 'a_in', 'a_out', 'b_in', 'b_out', 'w_in', 'w_out', 'h_in', 'h_out'): if hasattr(sky, attr): assert u.allclose(getattr(sky_rt, attr), getattr(sky, attr), rtol=1e-6) @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', SHEAR_ANGLES_DEG) def test_pixel_sky_pixel(self, sheared_wcs, case, theta_deg): _, pix = _build_pair(case, theta_deg * u.deg) pix_rt = pix.to_sky(sheared_wcs).to_pixel(sheared_wcs) diff = _angle_diff_deg(pix_rt.theta, theta_deg * u.deg) assert abs(diff) < 1e-6 class TestCircularInputAnglePreserved: """ Verify that the SVD path preserves the input rotation angle when the input rectangular/elliptical aperture is shape-degenerate (width == height). The converted theta must round-trip exactly. For a circular shape the SVD principal axis is otherwise arbitrary, so the helper falls back to the mapped width semiaxis direction to preserve orientation. """ @pytest.mark.parametrize('theta_deg', SHEAR_ANGLES_DEG) @pytest.mark.parametrize( 'sky_cls', [SkyRectangularAperture, SkyEllipticalAperture]) def test_square_sky_roundtrip(self, rotated_wcs, sky_cls, theta_deg): if sky_cls is SkyRectangularAperture: sky = SkyRectangularAperture(CENTER, w=2 * u.arcsec, h=2 * u.arcsec, theta=theta_deg * u.deg) else: sky = SkyEllipticalAperture(CENTER, a=2 * u.arcsec, b=2 * u.arcsec, theta=theta_deg * u.deg) sky_rt = sky.to_pixel(rotated_wcs).to_sky(rotated_wcs) diff = _angle_diff_deg(sky_rt.theta, theta_deg * u.deg) assert abs(diff) < 1e-6 @pytest.mark.parametrize('theta_deg', SHEAR_ANGLES_DEG) @pytest.mark.parametrize( 'pix_cls', [RectangularAperture, EllipticalAperture]) def test_square_pixel_roundtrip(self, rotated_wcs, pix_cls, theta_deg): if pix_cls is RectangularAperture: pix = RectangularAperture(PIX_CENTER, w=4, h=4, theta=theta_deg * u.deg) else: pix = EllipticalAperture(PIX_CENTER, a=4, b=4, theta=theta_deg * u.deg) pix_rt = pix.to_sky(rotated_wcs).to_pixel(rotated_wcs) diff = _angle_diff_deg(pix_rt.theta, theta_deg * u.deg) assert abs(diff) < 1e-6 class TestFlippedParityWCS: """ Regression tests for a flipped-parity WCS (North down, East left, with a positive-determinant pixel scale matrix). Such a WCS previously produced apertures that were mirrored about the x-axis. For the flipped axis-aligned WCS, North = -y and East = -x, so the sky/pixel angle relation is ``pixel.theta == 270 deg - sky.theta`` (mod 360). """ def test_flipped_wcs_has_positive_parity(self, flipped_wcs): """ The flipped WCS fixture must have a positive-determinant pixel scale matrix (parity = +1). """ assert np.linalg.det(flipped_wcs.pixel_scale_matrix) > 0 def test_north_is_minus_y(self, flipped_wcs): """ A sky aperture at PA=0 (North) maps to a pixel aperture pointing along -y (down), i.e., pixel theta = 270 deg. """ sky = SkyEllipticalAperture(CENTER, a=2 * u.arcsec, b=1 * u.arcsec, theta=0 * u.deg) pix = sky.to_pixel(flipped_wcs) diff = _angle_diff_deg(pix.theta, 270 * u.deg) assert abs(diff) < 2e-5 def test_east_is_minus_x(self, flipped_wcs): """ A sky aperture at PA=90 deg (East) maps to a pixel aperture pointing along -x (left), i.e., pixel theta = 180 deg. """ sky = SkyEllipticalAperture(CENTER, a=2 * u.arcsec, b=1 * u.arcsec, theta=90 * u.deg) pix = sky.to_pixel(flipped_wcs) diff = _angle_diff_deg(pix.theta, 180 * u.deg) assert abs(diff) < 2e-5 @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', ANGLES_DEG) def test_sky_to_pixel_theta(self, flipped_wcs, case, theta_deg): """ Verify ``sky.to_pixel`` returns ``270 deg - theta`` (mod 360) for the flipped-parity WCS (not the mirrored value). """ sky, _ = _build_pair(case, theta_deg * u.deg) pix = sky.to_pixel(flipped_wcs) diff = _angle_diff_deg(pix.theta, (270 - theta_deg) * u.deg) assert abs(diff) < 2e-5 @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', ANGLES_DEG) def test_pixel_to_sky_theta(self, flipped_wcs, case, theta_deg): """ Verify ``pixel.to_sky`` returns ``270 deg - theta`` (mod 360) for the flipped-parity WCS (the inverse of the above relation). """ _, pix = _build_pair(case, theta_deg * u.deg) sky = pix.to_sky(flipped_wcs) diff = _angle_diff_deg(sky.theta, (270 - theta_deg) * u.deg) assert abs(diff) < 2e-5 @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', ANGLES_DEG) def test_sky_pixel_sky_roundtrip_theta(self, flipped_wcs, case, theta_deg): """ Verify ``sky -> pixel -> sky`` preserves the original theta for the flipped-parity WCS. """ sky, _ = _build_pair(case, theta_deg * u.deg) sky_rt = sky.to_pixel(flipped_wcs).to_sky(flipped_wcs) diff = _angle_diff_deg(sky_rt.theta, theta_deg * u.deg) assert abs(diff) < 2e-5 def _make_nonsquare_wcs(): """ Nondistorted TAN WCS with nonsquare pixels (0.03 x 0.05 deg). """ wcs = WCS(naxis=2) wcs.wcs.crpix = list(PIX_CENTER) wcs.wcs.crval = [CENTER.ra.deg, CENTER.dec.deg] wcs.wcs.cdelt = [-0.03, 0.05] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs def _pixel_ellipse_boundary(aperture, n_points=90): """ Return the (x, y) pixel coordinates of points on the boundary of a single-position elliptical pixel aperture. """ t = np.linspace(0, 2 * np.pi, n_points, endpoint=False) theta = aperture.theta if not isinstance(theta, float): theta = theta.to_value(u.rad) x0, y0 = np.atleast_2d(aperture.positions)[0] xe = aperture.a * np.cos(t) ye = aperture.b * np.sin(t) x = x0 + xe * np.cos(theta) - ye * np.sin(theta) y = y0 + xe * np.sin(theta) + ye * np.cos(theta) return x, y class TestCircleAsEllipse: """ Tests for the ``as_ellipse`` keyword of the circular aperture ``to_pixel`` and ``to_sky`` methods. A circle on the sky maps to an ellipse in pixels (and vice versa) whenever the pixels are not square or the WCS is sheared. The keyword returns that exact ellipse instead of the mean-scale circle. """ NONSQUARE_X_ARCSEC = 0.03 * 3600 NONSQUARE_Y_ARCSEC = 0.05 * 3600 def test_default_returns_circle(self, simple_wcs): sky = SkyCircularAperture(CENTER, r=1 * u.arcsec) assert isinstance(sky.to_pixel(simple_wcs), CircularAperture) pix = CircularAperture(PIX_CENTER, r=2) assert isinstance(pix.to_sky(simple_wcs), SkyCircularAperture) def test_sky_to_pixel_nonsquare(self): wcs = _make_nonsquare_wcs() sky = SkyCircularAperture(CENTER, r=1 * u.arcsec) aper = sky.to_pixel(wcs, as_ellipse=True) assert isinstance(aper, EllipticalAperture) assert_allclose(aper.a, 1 / self.NONSQUARE_X_ARCSEC, rtol=1e-6) assert_allclose(aper.b, 1 / self.NONSQUARE_Y_ARCSEC, rtol=1e-6) # The major axis is along x assert_allclose(np.sin(aper.theta.to_value(u.rad)), 0, atol=1e-6) def test_pixel_to_sky_nonsquare(self): wcs = _make_nonsquare_wcs() # Place the aperture on the tangent point (CRPIX is 1-based), # where the pixel y-axis points exactly North. One pixel away # the meridians have already converged by 0.02 deg with these # 0.05 deg pixels. pix = CircularAperture((PIX_CENTER[0] - 1, PIX_CENTER[1] - 1), r=2) aper = pix.to_sky(wcs, as_ellipse=True) assert isinstance(aper, SkyEllipticalAperture) y_arcsec = self.NONSQUARE_Y_ARCSEC * u.arcsec x_arcsec = self.NONSQUARE_X_ARCSEC * u.arcsec assert_quantity_allclose(aper.a, 2 * y_arcsec, rtol=1e-6) assert_quantity_allclose(aper.b, 2 * x_arcsec, rtol=1e-6) # The major axis is along y, which is North, so the position # angle is 0 mod 180. assert_allclose(np.sin(aper.theta.to_value(u.rad)), 0, atol=1e-6) def test_sky_annulus_to_pixel_nonsquare(self): wcs = _make_nonsquare_wcs() sky = SkyCircularAnnulus(CENTER, r_in=1 * u.arcsec, r_out=2 * u.arcsec) aper = sky.to_pixel(wcs, as_ellipse=True) assert isinstance(aper, EllipticalAnnulus) assert_allclose(aper.a_in, 1 / self.NONSQUARE_X_ARCSEC, rtol=1e-6) assert_allclose(aper.a_out, 2 / self.NONSQUARE_X_ARCSEC, rtol=1e-6) assert_allclose(aper.b_in, 1 / self.NONSQUARE_Y_ARCSEC, rtol=1e-6) assert_allclose(aper.b_out, 2 / self.NONSQUARE_Y_ARCSEC, rtol=1e-6) def test_pixel_annulus_to_sky_nonsquare(self): wcs = _make_nonsquare_wcs() pix = CircularAnnulus(PIX_CENTER, r_in=2, r_out=4) aper = pix.to_sky(wcs, as_ellipse=True) assert isinstance(aper, SkyEllipticalAnnulus) y_arcsec = self.NONSQUARE_Y_ARCSEC * u.arcsec x_arcsec = self.NONSQUARE_X_ARCSEC * u.arcsec assert_quantity_allclose(aper.a_in, 2 * y_arcsec, rtol=1e-6) assert_quantity_allclose(aper.a_out, 4 * y_arcsec, rtol=1e-6) assert_quantity_allclose(aper.b_in, 2 * x_arcsec, rtol=1e-6) assert_quantity_allclose(aper.b_out, 4 * x_arcsec, rtol=1e-6) def test_square_pixels_give_circle(self, simple_wcs): sky = SkyCircularAperture(CENTER, r=1 * u.arcsec) aper = sky.to_pixel(simple_wcs, as_ellipse=True) circle = sky.to_pixel(simple_wcs) assert_allclose(aper.a, aper.b, rtol=1e-8) assert_allclose(aper.a, circle.r, rtol=1e-8) @pytest.mark.parametrize('wcs_name', ['sheared_wcs', 'sip_wcs']) def test_pixel_ellipse_traces_sky_circle(self, wcs_name, request): # Every point on the pixel ellipse boundary must lie at the # circle radius from the center on the sky. wcs = request.getfixturevalue(wcs_name) radius = 2 * u.arcsec sky = SkyCircularAperture(CENTER, r=radius) aper = sky.to_pixel(wcs, as_ellipse=True) x, y = _pixel_ellipse_boundary(aper) separations = CENTER.separation(wcs.pixel_to_world(x, y)) assert_quantity_allclose(separations, radius, rtol=1e-4) def test_sky_circle_roundtrip_through_ellipse(self, sheared_wcs): sky = SkyCircularAperture(CENTER, r=2 * u.arcsec) back = sky.to_pixel(sheared_wcs, as_ellipse=True).to_sky(sheared_wcs) assert_quantity_allclose(back.a, 2 * u.arcsec, rtol=1e-6) assert_quantity_allclose(back.b, 2 * u.arcsec, rtol=1e-6) def test_pixel_circle_roundtrip_through_ellipse(self, sheared_wcs): pix = CircularAperture(PIX_CENTER, r=3) back = pix.to_sky(sheared_wcs, as_ellipse=True).to_pixel(sheared_wcs) assert_allclose(back.a, 3, rtol=1e-6) assert_allclose(back.b, 3, rtol=1e-6) def test_multiple_positions(self): wcs = _make_nonsquare_wcs() positions = [(10.5, 10.5), (12.0, 8.0)] pix = CircularAperture(positions, r=2) aper = pix.to_sky(wcs, as_ellipse=True) assert aper.positions.shape == (2,) assert np.isscalar(aper.a.value) sky = SkyCircularAperture(wcs.pixel_to_world(*np.transpose(positions)), r=1 * u.arcsec) aper = sky.to_pixel(wcs, as_ellipse=True) assert aper.positions.shape == (2, 2) assert np.isscalar(aper.a) def test_as_ellipse_is_keyword_only(self, simple_wcs): sky = SkyCircularAperture(CENTER, r=1 * u.arcsec) match = 'positional argument' with pytest.raises(TypeError, match=match): sky.to_pixel(simple_wcs, True) # noqa: FBT003 # Sky apertures and to_pixel keywords for the single-evaluation tests _SKY_APERTURE_CASES = [ pytest.param(SkyCircularAperture(CENTER, r=1 * u.arcsec), {}, id='circle'), pytest.param(SkyCircularAperture(CENTER, r=1 * u.arcsec), {'as_ellipse': True}, id='circle_ellipse'), pytest.param(SkyCircularAnnulus(CENTER, r_in=1 * u.arcsec, r_out=2 * u.arcsec), {}, id='circle_annulus'), pytest.param(SkyCircularAnnulus(CENTER, r_in=1 * u.arcsec, r_out=2 * u.arcsec), {'as_ellipse': True}, id='circle_annulus_ellipse'), pytest.param(SkyEllipticalAperture(CENTER, a=2 * u.arcsec, b=1 * u.arcsec, theta=30 * u.deg), {}, id='ellipse'), pytest.param(SkyEllipticalAnnulus(CENTER, a_in=1 * u.arcsec, a_out=2 * u.arcsec, b_out=1 * u.arcsec, theta=30 * u.deg), {}, id='ellipse_annulus'), pytest.param(SkyRectangularAperture(CENTER, w=2 * u.arcsec, h=1 * u.arcsec, theta=30 * u.deg), {}, id='rectangle'), pytest.param(SkyRectangularAnnulus(CENTER, w_in=1 * u.arcsec, w_out=2 * u.arcsec, h_out=1 * u.arcsec, theta=30 * u.deg), {}, id='rectangle_annulus'), ] # Pixel apertures for the single-evaluation tests _PIXEL_APERTURE_CASES = [ pytest.param(CircularAperture(PIX_CENTER, r=3.0), id='circle'), pytest.param(CircularAnnulus(PIX_CENTER, r_in=3.0, r_out=5.0), id='circle_annulus'), pytest.param(EllipticalAperture(PIX_CENTER, a=5.0, b=3.0, theta=0.5), id='ellipse'), pytest.param(EllipticalAnnulus(PIX_CENTER, a_in=3.0, a_out=5.0, b_out=3.0, theta=0.5), id='ellipse_annulus'), pytest.param(RectangularAperture(PIX_CENTER, w=5.0, h=3.0, theta=0.5), id='rectangle'), pytest.param(RectangularAnnulus(PIX_CENTER, w_in=3.0, w_out=5.0, h_out=3.0, theta=0.5), id='rectangle_annulus'), ] @pytest.fixture def strongly_sheared_wcs(): """ A TAN WCS with a strong shear, for which converting the inner and outer shapes of an annulus separately can give an inner shape that does not fit inside the outer one. """ cdelt = 0.1 / 3600 wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [CENTER.ra.deg, CENTER.dec.deg] wcs.wcs.cd = [[-cdelt, 0.2 * cdelt], [0.0, cdelt]] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs class TestAnnulusInnerShape: """ Tests the conversion of an annulus whose inner shape has a different aspect ratio from the outer one. The outer shape must match the conversion of the matching simple aperture. The inner shape keeps its input size ratios to the outer shape, so the converted annulus is valid for any WCS and the inverse conversion recovers the input. """ @pytest.mark.parametrize('wcs_name', ['sheared_wcs', 'sip_wcs', 'strongly_sheared_wcs']) def test_elliptical_pixel_to_sky(self, wcs_name, request): wcs = request.getfixturevalue(wcs_name) theta = 45 * u.deg annulus = EllipticalAnnulus(PIX_CENTER, a_in=2, a_out=4, b_out=2, b_in=1.5, theta=theta) outer = EllipticalAperture(PIX_CENTER, a=4, b=2, theta=theta) sky = annulus.to_sky(wcs) sky_outer = outer.to_sky(wcs) assert_quantity_allclose(sky.a_out, sky_outer.a, rtol=1e-12) assert_quantity_allclose(sky.b_out, sky_outer.b, rtol=1e-12) assert_quantity_allclose(sky.a_in / sky.a_out, 2 / 4, rtol=1e-12) assert_quantity_allclose(sky.b_in / sky.b_out, 1.5 / 2, rtol=1e-12) assert_quantity_allclose(sky.theta, sky_outer.theta, atol=1e-10 * u.deg) @pytest.mark.parametrize('wcs_name', ['sheared_wcs', 'sip_wcs', 'strongly_sheared_wcs']) def test_elliptical_sky_to_pixel(self, wcs_name, request): wcs = request.getfixturevalue(wcs_name) theta = 30 * u.deg annulus = SkyEllipticalAnnulus(CENTER, a_in=1 * u.arcsec, a_out=2 * u.arcsec, b_out=1 * u.arcsec, b_in=0.75 * u.arcsec, theta=theta) outer = SkyEllipticalAperture(CENTER, a=2 * u.arcsec, b=1 * u.arcsec, theta=theta) pix = annulus.to_pixel(wcs) pix_outer = outer.to_pixel(wcs) assert_allclose(pix.a_out, pix_outer.a, rtol=1e-12) assert_allclose(pix.b_out, pix_outer.b, rtol=1e-12) assert_allclose(pix.a_in / pix.a_out, 1 / 2, rtol=1e-12) assert_allclose(pix.b_in / pix.b_out, 0.75 / 1, rtol=1e-12) assert_quantity_allclose(pix.theta, pix_outer.theta, atol=1e-10 * u.deg) @pytest.mark.parametrize('wcs_name', ['sheared_wcs', 'sip_wcs', 'strongly_sheared_wcs']) def test_rectangular_pixel_to_sky(self, wcs_name, request): wcs = request.getfixturevalue(wcs_name) theta = 45 * u.deg annulus = RectangularAnnulus(PIX_CENTER, w_in=2, w_out=4, h_out=2, h_in=1.5, theta=theta) outer = RectangularAperture(PIX_CENTER, w=4, h=2, theta=theta) sky = annulus.to_sky(wcs) sky_outer = outer.to_sky(wcs) assert_quantity_allclose(sky.w_out, sky_outer.w, rtol=1e-12) assert_quantity_allclose(sky.h_out, sky_outer.h, rtol=1e-12) assert_quantity_allclose(sky.w_in / sky.w_out, 2 / 4, rtol=1e-12) assert_quantity_allclose(sky.h_in / sky.h_out, 1.5 / 2, rtol=1e-12) assert_quantity_allclose(sky.theta, sky_outer.theta, atol=1e-10 * u.deg) @pytest.mark.parametrize('wcs_name', ['sheared_wcs', 'sip_wcs', 'strongly_sheared_wcs']) def test_rectangular_sky_to_pixel(self, wcs_name, request): wcs = request.getfixturevalue(wcs_name) theta = 30 * u.deg annulus = SkyRectangularAnnulus(CENTER, w_in=1 * u.arcsec, w_out=2 * u.arcsec, h_out=1 * u.arcsec, h_in=0.75 * u.arcsec, theta=theta) outer = SkyRectangularAperture(CENTER, w=2 * u.arcsec, h=1 * u.arcsec, theta=theta) pix = annulus.to_pixel(wcs) pix_outer = outer.to_pixel(wcs) assert_allclose(pix.w_out, pix_outer.w, rtol=1e-12) assert_allclose(pix.h_out, pix_outer.h, rtol=1e-12) assert_allclose(pix.w_in / pix.w_out, 1 / 2, rtol=1e-12) assert_allclose(pix.h_in / pix.h_out, 0.75 / 1, rtol=1e-12) assert_quantity_allclose(pix.theta, pix_outer.theta, atol=1e-10 * u.deg) @pytest.mark.parametrize('annulus', [ EllipticalAnnulus(PIX_CENTER, a_in=2.951, a_out=4.887, b_out=4.604, b_in=4.414, theta=89.98 * u.deg), RectangularAnnulus(PIX_CENTER, w_in=4.0, w_out=4.2, h_out=4.3, h_in=3.0, theta=0.0), ]) def test_pixel_to_sky_nearly_equal_sizes(self, annulus, strongly_sheared_wcs): """ Test annuli for which the separately converted inner shape does not fit inside the converted outer shape. """ sky = annulus.to_sky(strongly_sheared_wcs) pix = sky.to_pixel(strongly_sheared_wcs) for name in annulus._params[1:-1]: assert_allclose(getattr(pix, name), getattr(annulus, name), rtol=1e-8) @pytest.mark.parametrize('annulus', [ SkyEllipticalAnnulus(CENTER, a_in=0.3104 * u.arcsec, a_out=0.4239 * u.arcsec, b_out=0.365 * u.arcsec, b_in=0.351 * u.arcsec, theta=66.49 * u.deg), SkyRectangularAnnulus(CENTER, w_in=0.3523 * u.arcsec, w_out=0.5945 * u.arcsec, h_out=0.5219 * u.arcsec, h_in=0.5094 * u.arcsec, theta=175.32 * u.deg), ]) def test_sky_to_pixel_nearly_equal_sizes(self, annulus, strongly_sheared_wcs): """ Test annuli for which the separately converted inner shape does not fit inside the converted outer shape. """ pix = annulus.to_pixel(strongly_sheared_wcs) sky = pix.to_sky(strongly_sheared_wcs) for name in annulus._params[1:-1]: assert_quantity_allclose(getattr(sky, name), getattr(annulus, name), rtol=1e-8) class TestSingleWCSEvaluation: """ Tests that each conversion evaluates the WCS as few times as possible. A sky-to-pixel conversion inverts the WCS once, for the aperture positions, reuses that pixel position for the shape conversion, and evaluates the low-level forward transform once for the local Jacobian. A pixel-to-sky conversion evaluates the high-level forward transform once for the positions and the low-level one once for the Jacobian. The annuli convert both of their shapes within those same evaluations. """ @pytest.mark.parametrize(('aperture', 'kwargs'), _SKY_APERTURE_CASES) @pytest.mark.parametrize('wcs_name', ['simple_wcs', 'sip_wcs']) def test_sky_to_pixel(self, aperture, kwargs, wcs_name, request): real_wcs = request.getfixturevalue(wcs_name) expected = aperture.to_pixel(real_wcs, **kwargs) wcs = CountingWCS(real_wcs) result = aperture.to_pixel(wcs, **kwargs) assert wcs.n_world_to_pixel == 1 assert wcs.n_pixel_to_world_values == 1 assert wcs.n_pixel_to_world == 0 assert type(result) is type(expected) assert_allclose(result.positions, expected.positions, atol=1e-8) @pytest.mark.parametrize('aperture', _PIXEL_APERTURE_CASES) @pytest.mark.parametrize('wcs_name', ['simple_wcs', 'sip_wcs']) def test_pixel_to_sky(self, aperture, wcs_name, request): real_wcs = request.getfixturevalue(wcs_name) expected = aperture.to_sky(real_wcs) wcs = CountingWCS(real_wcs) result = aperture.to_sky(wcs) assert wcs.n_world_to_pixel == 0 assert wcs.n_pixel_to_world == 1 assert wcs.n_pixel_to_world_values == 1 assert type(result) is type(expected) assert result.positions.separation(expected.positions).arcsec < 1e-9 def _expected_pixel_theta(wcs, position, theta): """ Return the pixel angle (deg, modulo 180) of the width axis of a sky aperture, found by projecting a point on that axis through the WCS. The sky ``theta`` is a position angle measured in the frame of ``position``. A point offset along it is projected with the WCS, which transforms it to the WCS frame. """ tip = position.directional_offset_by(theta, 1 * u.arcsec) x0, y0 = wcs.world_to_pixel(position) x1, y1 = wcs.world_to_pixel(tip) return np.rad2deg(np.arctan2(y1 - y0, x1 - x0)) % 180 def _frame_mismatch_params(): """ Return (position, wcs fixture name) pairs whose position frame differs from the WCS celestial frame. """ return [pytest.param(CENTER, 'galactic_wcs', id='icrs_on_galactic'), pytest.param(CENTER.galactic, 'simple_wcs', id='galactic_on_icrs')] class TestPositionFrameDiffersFromWCSFrame: """ Regression tests for a sky aperture whose position frame differs from the WCS celestial frame. The sky ``theta`` is a position angle measured in the frame of the aperture position, so converting to pixels must include the rotation between that frame and the WCS frame. """ @pytest.mark.parametrize(('position', 'wcs_name'), _frame_mismatch_params()) @pytest.mark.parametrize('case', DIRECTED_CASES) @pytest.mark.parametrize('theta_deg', ANGLES_DEG) def test_sky_to_pixel_theta(self, position, wcs_name, case, theta_deg, request): wcs = request.getfixturevalue(wcs_name) theta = theta_deg * u.deg sky = case['sky_cls'](position, **{**case['sky_kw'], 'theta': theta}) pix = sky.to_pixel(wcs) expected = _expected_pixel_theta(wcs, position, theta) diff = (pix.theta.to_value(u.deg) - expected + 90) % 180 - 90 assert abs(diff) < 1e-4 @pytest.mark.parametrize('case', DIRECTED_CASES) def test_multiple_positions(self, galactic_wcs, case): """ Test that the frame rotation is evaluated at the first aperture position. """ theta = 30 * u.deg positions = SkyCoord([CENTER.ra, CENTER.ra + 1 * u.arcsec], [CENTER.dec, CENTER.dec]) sky = case['sky_cls'](positions, **{**case['sky_kw'], 'theta': theta}) pix = sky.to_pixel(galactic_wcs) expected = _expected_pixel_theta(galactic_wcs, positions[0], theta) diff = (pix.theta.to_value(u.deg) - expected + 90) % 180 - 90 assert abs(diff) < 1e-4 def test_icrs_aperture_on_galactic_wcs(self): """ Test that an ICRS aperture projected onto a Galactic WCS centered on it is rotated by the position angle of ICRS North in Galactic coordinates (regions issue #712). """ position = SkyCoord(0, 0, unit='deg', frame='icrs') aperture = SkyEllipticalAperture(position, a=0.5 * u.deg, b=0.25 * u.deg, theta=0 * u.deg) wcs = WCS(naxis=2) wcs.wcs.crval = [position.galactic.l.deg, position.galactic.b.deg] wcs.wcs.ctype = ['GLON-TAN', 'GLAT-TAN'] wcs.wcs.cdelt = [-0.1, 0.1] wcs.wcs.crpix = [25.5, 25.5] wcs.wcs.cunit = ['deg', 'deg'] pix = aperture.to_pixel(wcs) assert_allclose(pix.positions, (24.5, 24.5)) assert_allclose(pix.a, 5.0, rtol=1e-6) assert_allclose(pix.b, 2.5, rtol=1e-6) assert_quantity_allclose(pix.theta, 113.47980599 * u.deg, atol=1e-6 * u.deg) astropy-photutils-24be529/photutils/aperture/tests/test_polygon.py000066400000000000000000001517251526452531300257470ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the polygon aperture module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.tests.helper import assert_quantity_allclose from astropy.wcs import WCS from numpy.testing import assert_allclose from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.photometry import AperturePhotometry from photutils.aperture.polygon import (PolygonAperture, SkyPolygonAperture, _polygon_is_simple, _segments_intersect, _signed_polygon_area, _validate_simple_polygon, _vertices_centroid) from photutils.aperture.rectangle import RectangularAperture from photutils.aperture.tests.test_aperture_common import BaseTestAperture from photutils.utils._optional_deps import HAS_MATPLOTLIB, HAS_REGIONS POSITIONS = [(10.0, 20.0), (30.0, 40.0), (50.0, 60.0), (70.0, 80.0)] RA, DEC = np.transpose(POSITIONS) SKYCOORD = SkyCoord(ra=RA, dec=DEC, unit='deg') UNIT = u.arcsec SQUARE_OFFSETS = np.array([[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]) TRIANGLE_OFFSETS = np.array([[0.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]) def _make_wcs(): """ Tangent-plane WCS at (180, 0). """ wcs = WCS(naxis=2) wcs.wcs.crpix = [50.5, 50.5] wcs.wcs.cdelt = [-0.01, 0.01] wcs.wcs.crval = [180.0, 0.0] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs def _pentagram(): """ A five-pointed (self-intersecting) star drawn by connecting every other vertex of a regular pentagon. Its edges self-intersect, but its signed area is nonzero so it is not caught by the degeneracy check. """ ang = np.pi / 2 + np.arange(5) * 2 * np.pi * 2 / 5 return np.column_stack([np.cos(ang), np.sin(ang)]) def _concave_star(): """ A ten-pointed star outline (alternating inner/outer radii). It is concave but simple (non-self-intersecting). """ ang = np.pi / 2 + np.arange(10) * np.pi / 5 radius = np.where(np.arange(10) % 2 == 0, 5.0, 2.0) return np.column_stack([radius * np.cos(ang), radius * np.sin(ang)]) class TestPolygonAperture(BaseTestAperture): aperture = PolygonAperture(POSITIONS, SQUARE_OFFSETS) def test_index(self): aper = self.aperture[2] assert isinstance(aper, PolygonAperture) assert aper.isscalar assert_allclose(aper.positions, self.aperture.positions[2]) assert_allclose(aper.vertex_offsets, self.aperture.vertex_offsets) def test_slice(self): aper = self.aperture[0:2] assert isinstance(aper, PolygonAperture) assert len(aper) == 2 assert_allclose(aper.positions, self.aperture.positions[0:2]) assert_allclose(aper.vertex_offsets, self.aperture.vertex_offsets) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.vertex_offsets = SQUARE_OFFSETS * 2 assert aper != self.aperture def test_repr(self): assert 'PolygonAperture' in repr(self.aperture) assert 'PolygonAperture' in str(self.aperture) def test_perimeter(self): # Unit square (side 2) has perimeter 8. assert_allclose(self.aperture.perimeter, 8.0) def test_perimeter_triangle(self): aper = PolygonAperture((0.0, 0.0), TRIANGLE_OFFSETS) expected = 2.0 * np.sqrt(5.0) + 2.0 assert_allclose(aper.perimeter, expected, rtol=1e-12) def test_perimeter_regular_polygon(self): # Regular hexagon: perimeter = n * 2 * r * sin(pi / n). aper = PolygonAperture.from_regular_polygon((0.0, 0.0), 6, 5.0) expected = 6.0 * 2.0 * 5.0 * np.sin(np.pi / 6) assert_allclose(aper.perimeter, expected, rtol=1e-12) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self): import matplotlib.pyplot as plt fig, ax = plt.subplots() self.aperture.plot(ax=ax) plt.close(fig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_returns_patches(self): from matplotlib.patches import Patch patches = self.aperture._to_patch() assert isinstance(patches, list) for patch in patches: assert isinstance(patch, Patch) class TestSkyPolygonAperture(BaseTestAperture): aperture = SkyPolygonAperture(SKYCOORD, SQUARE_OFFSETS * UNIT) def test_index(self): aper = self.aperture[2] assert isinstance(aper, SkyPolygonAperture) assert aper.isscalar assert_quantity_allclose(aper.positions.ra, self.aperture.positions[2].ra) assert_quantity_allclose(aper.vertex_offsets, self.aperture.vertex_offsets) def test_slice(self): aper = self.aperture[0:2] assert isinstance(aper, SkyPolygonAperture) assert len(aper) == 2 assert_quantity_allclose(aper.positions.ra, self.aperture.positions[0:2].ra) assert_quantity_allclose(aper.vertex_offsets, self.aperture.vertex_offsets) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.vertex_offsets = SQUARE_OFFSETS * 2 * UNIT assert aper != self.aperture def test_perimeter(self): # Unit square (side 2 arcsec): perimeter = 8 arcsec. assert_quantity_allclose(self.aperture.perimeter, 8.0 * u.arcsec) def test_perimeter_triangle(self): aper = SkyPolygonAperture(SKYCOORD[0], TRIANGLE_OFFSETS * u.arcsec) expected = (2.0 * np.sqrt(5.0) + 2.0) * u.arcsec assert_quantity_allclose(aper.perimeter, expected, rtol=1e-12) def test_perimeter_unit(self): assert self.aperture.perimeter.unit == u.arcsec def test_perimeter_regular_polygon(self): # Regular hexagon: perimeter = n * 2 * r * sin(pi / n). aper = SkyPolygonAperture.from_regular_polygon( SKYCOORD[0], 6, 5.0 * u.arcsec) expected = 6.0 * 2.0 * 5.0 * np.sin(np.pi / 6) * u.arcsec assert_quantity_allclose(aper.perimeter, expected, rtol=1e-12) def _matches_up_to_cyclic_reversal(a, b, *, atol=1e-10): n = len(a) if len(b) != n: return False for direction in (b, b[::-1]): for k in range(n): shifted = np.roll(direction, -k, axis=0) if np.allclose(a, shifted, atol=atol): return True return False class TestPolygonApertureTheta: """ Tests for the ``theta`` attribute of `PolygonAperture`. """ def test_theta_zero(self): aper = PolygonAperture.from_regular_polygon((10, 20), 6, 5.0, theta=0.0) assert isinstance(aper.theta, u.Quantity) assert_quantity_allclose(aper.theta, 0.0 * u.deg, atol=1e-10 * u.deg) @pytest.mark.parametrize('n_vertices', [3, 4, 5, 6, 8]) def test_theta_full_turn(self, n_vertices): """ Test that a rotation by a full turn is reported as 0 degrees. """ aper = PolygonAperture.from_regular_polygon((0, 0), n_vertices, 5.0, theta=2.0 * np.pi) assert aper.theta < 360.0 * u.deg assert_quantity_allclose(aper.theta, 0.0 * u.deg, atol=1e-9 * u.deg) def test_theta_unit(self): aper = PolygonAperture.from_regular_polygon((10, 20), 5, 3.0, theta=np.pi / 3) assert aper.theta.unit == u.deg @pytest.mark.parametrize('theta_rad', [ 0.0, np.pi / 6, np.pi / 4, np.pi / 2, np.pi, 3 * np.pi / 2, ]) def test_theta_roundtrip_radians(self, theta_rad): aper = PolygonAperture.from_regular_polygon((10, 20), 6, 5.0, theta=theta_rad) expected = np.degrees(theta_rad) % 360.0 assert_quantity_allclose(aper.theta, expected * u.deg, atol=1e-10 * u.deg) @pytest.mark.parametrize('theta_deg', [0.0, 45.0, 90.0, 135.0, 180.0, 270.0, 359.0]) def test_theta_roundtrip_quantity(self, theta_deg): aper = PolygonAperture.from_regular_polygon((10, 20), 6, 5.0, theta=theta_deg * u.deg) assert_quantity_allclose(aper.theta, theta_deg * u.deg, atol=1e-10 * u.deg) def test_theta_negative_normalized(self): # Negative theta is normalized to [0, 360) deg. aper = PolygonAperture.from_regular_polygon((10, 20), 6, 5.0, theta=-np.pi / 4) assert_quantity_allclose(aper.theta, 315.0 * u.deg, atol=1e-10 * u.deg) @pytest.mark.parametrize('n_vertices', [3, 4, 5, 6, 8, 10]) def test_theta_different_n_vertices(self, n_vertices): aper = PolygonAperture.from_regular_polygon((0, 0), n_vertices, 1.0, theta=0.0) assert_quantity_allclose(aper.theta, 0.0 * u.deg, atol=1e-10 * u.deg) def test_theta_nonregular_raises(self): # Nonregular polygon raises ValueError. offsets = [(1.0, 0.0), (2.0, 1.0), (0.0, 2.0)] aper = PolygonAperture((10, 20), offsets) match = "'theta' is only defined for a regular polygon aperture" with pytest.raises(ValueError, match=match): _ = aper.theta class TestSkyPolygonApertureTheta: """ Tests for the ``theta`` attribute of `SkyPolygonAperture`. """ @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 90.0, 200.0]) def test_theta_is_position_angle(self, theta_deg): """ Test that ``theta`` is the position angle of the first vertex, measured from North toward East. """ center = SKYCOORD[0] aper = SkyPolygonAperture.from_regular_polygon( center, 5, 5.0 * u.arcsec, theta=theta_deg * u.deg) pos_angle = center.position_angle(aper.vertices[0]) assert_quantity_allclose(pos_angle.to(u.deg), theta_deg * u.deg, atol=1e-6 * u.deg) assert_quantity_allclose(aper.theta, theta_deg * u.deg, atol=1e-9 * u.deg) @pytest.mark.parametrize('n_vertices', [3, 4, 5, 6, 8]) def test_theta_full_turn(self, n_vertices): """ Test that a rotation by a full turn is reported as 0 degrees. """ aper = SkyPolygonAperture.from_regular_polygon( SKYCOORD[0], n_vertices, 5.0 * u.arcsec, theta=360.0 * u.deg) assert aper.theta < 360.0 * u.deg assert_quantity_allclose(aper.theta, 0.0 * u.deg, atol=1e-9 * u.deg) def test_theta_zero(self): aper = SkyPolygonAperture.from_regular_polygon( SKYCOORD[0], 6, 5.0 * u.arcsec, theta=0.0 * u.deg) assert isinstance(aper.theta, u.Quantity) assert_quantity_allclose(aper.theta, 0.0 * u.deg, atol=1e-10 * u.deg) def test_theta_unit(self): aper = SkyPolygonAperture.from_regular_polygon( SKYCOORD[0], 5, 3.0 * u.arcsec, theta=60.0 * u.deg) assert aper.theta.unit == u.deg @pytest.mark.parametrize('theta_deg', [ 0.0, 30.0, 45.0, 90.0, 135.0, 180.0, 270.0, ]) def test_theta_roundtrip(self, theta_deg): aper = SkyPolygonAperture.from_regular_polygon( SKYCOORD[0], 6, 5.0 * u.arcsec, theta=theta_deg * u.deg) assert_quantity_allclose(aper.theta, theta_deg * u.deg, atol=1e-10 * u.deg) def test_theta_negative_normalized(self): aper = SkyPolygonAperture.from_regular_polygon( SKYCOORD[0], 6, 5.0 * u.arcsec, theta=-45.0 * u.deg) assert_quantity_allclose(aper.theta, 315.0 * u.deg, atol=1e-10 * u.deg) def test_theta_nonregular_raises(self): offsets = np.array([(0.5, 0.0), (1.0, 0.5), (0.0, 1.0)]) * u.arcsec aper = SkyPolygonAperture(SKYCOORD[0], offsets) match = "'theta' is only defined for a regular polygon aperture" with pytest.raises(ValueError, match=match): _ = aper.theta @pytest.mark.parametrize('n_vertices', [3, 4, 5, 6, 8]) def test_theta_different_n_vertices(self, n_vertices): aper = SkyPolygonAperture.from_regular_polygon( SKYCOORD[0], n_vertices, 1.0 * u.arcsec, theta=0.0 * u.deg) assert_quantity_allclose(aper.theta, 0.0 * u.deg, atol=1e-10 * u.deg) class TestPolygonGeometryHelpers: """ Tests for the module-level polygon geometry helper functions. """ def test_signed_area_ccw_positive(self): verts = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) assert_allclose(_signed_polygon_area(verts), 1.0) def test_signed_area_cw_negative(self): verts = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]) assert_allclose(_signed_polygon_area(verts), -1.0) def test_centroid_square(self): verts = np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]) assert_allclose(_vertices_centroid(verts), [2.0, 2.0]) @pytest.mark.parametrize( ('p1', 'p2', 'p3', 'p4', 'expected'), [ # Proper crossing (segments straddle each other). ((0.0, 0.0), (2.0, 2.0), (0.0, 2.0), (2.0, 0.0), True), # Disjoint, noncollinear segments. ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0), False), # p1 lies on segment p3-p4 (d1 == 0). ((1.0, 0.0), (1.0, 1.0), (0.0, 0.0), (2.0, 0.0), True), # p2 lies on segment p3-p4 (d2 == 0). ((1.0, 1.0), (1.0, 0.0), (0.0, 0.0), (2.0, 0.0), True), # p3 lies on segment p1-p2 (d3 == 0). ((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (1.0, 1.0), True), # p4 lies on segment p1-p2 (d4 == 0). ((0.0, 0.0), (2.0, 0.0), (1.0, 1.0), (1.0, 0.0), True), # Collinear but nonoverlapping segments. ((0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), False), ]) def test_segments_intersect(self, p1, p2, p3, p4, expected): assert _segments_intersect(p1, p2, p3, p4) is expected def test_is_simple_square(self): assert _polygon_is_simple(SQUARE_OFFSETS) def test_is_simple_concave_star(self): assert _polygon_is_simple(_concave_star()) def test_is_simple_pentagram(self): star = _pentagram() # Sanity check: the pentagram has nonzero area, so it is not caught # by the degeneracy check and must be rejected by the simplicity # check. assert _signed_polygon_area(star) != 0.0 assert not _polygon_is_simple(star) class TestPolygonValidation: """ Tests for the simple-polygon validation applied when the vertex offsets are set. """ def test_reorders_clockwise(self): cw = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]) out = _validate_simple_polygon(cw) # Expect CCW order assert _signed_polygon_area(out) > 0 def test_rejects_wrong_shape(self): match = r"'vertex_offsets' must have shape \(n_vertices, 2\)" with pytest.raises(ValueError, match=match): _validate_simple_polygon(np.array([1.0, 2.0, 3.0])) def test_rejects_too_few(self): match = ("'vertex_offsets' must define a polygon with at " 'least 3 vertices') with pytest.raises(ValueError, match=match): _validate_simple_polygon(np.array([[0.0, 0.0], [1.0, 0.0]])) def test_rejects_nonfinite(self): bad = np.array([[0.0, 0.0], [np.nan, 0.0], [1.0, 1.0]]) match = "'vertex_offsets' must not contain any nonfinite values" with pytest.raises(ValueError, match=match): _validate_simple_polygon(bad) def test_rejects_collinear(self): bad = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) match = ("'vertex_offsets' defines a degenerate polygon with " r'zero area \(vertices are collinear or coincident\)') with pytest.raises(ValueError, match=match): _validate_simple_polygon(bad) def test_accepts_nonconvex(self): """ Test that a nonconvex (L-shaped) polygon is accepted. """ arrow = np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [2.0, 2.0], [2.0, 4.0], [0.0, 4.0]]) out = _validate_simple_polygon(arrow) assert out.shape == arrow.shape def test_rejects_self_intersecting(self): match = ("'vertex_offsets' must define a simple polygon, but " 'its edges self-intersect') with pytest.raises(ValueError, match=match): _validate_simple_polygon(_pentagram()) def test_pixel_aperture_rejects_self_intersecting(self): match = ("'vertex_offsets' must define a simple polygon, but " 'its edges self-intersect') with pytest.raises(ValueError, match=match): PolygonAperture((0.0, 0.0), _pentagram()) def test_rejects_repeated_consecutive_vertices(self): match = "'vertex_offsets' must not contain repeated consecutive" offsets = np.array([[0.0, 0.0], [0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]) with pytest.raises(ValueError, match=match): PolygonAperture((0.0, 0.0), offsets) # The last vertex repeats the first one offsets = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 0.0]]) with pytest.raises(ValueError, match=match): PolygonAperture((0.0, 0.0), offsets) match = "'vertices' must not contain repeated consecutive" with pytest.raises(ValueError, match=match): PolygonAperture.from_vertices(offsets) def test_sky_aperture_rejects_self_intersecting(self): pos = SkyCoord(ra=180.0, dec=0.0, unit='deg') match = ("'vertex_offsets' must define a simple polygon, but " 'its edges self-intersect') with pytest.raises(ValueError, match=match): SkyPolygonAperture(pos, _pentagram() * u.arcsec) def test_pixel_aperture_accepts_concave_star(self): aper = PolygonAperture((0.0, 0.0), _concave_star()) assert aper.vertex_offsets.shape == (10, 2) class TestPolygonConstruction: """ Tests for constructing a PolygonAperture and validating its vertex offsets. """ def test_construct_scalar(self): aper = PolygonAperture((10.0, 20.0), SQUARE_OFFSETS) assert aper.isscalar assert aper.shape == () assert aper.vertex_offsets.shape == (4, 2) assert_allclose(aper.area, 4.0) assert aper._xy_extents == (1.0, 1.0) def test_construct_array_positions(self): aper = PolygonAperture(POSITIONS, SQUARE_OFFSETS) assert not aper.isscalar assert aper.shape == (4,) assert len(aper) == 4 assert aper.vertices.shape == (4, 4, 2) expected_first = np.asarray(POSITIONS[0]) + SQUARE_OFFSETS assert_allclose(aper.vertices[0], expected_first) def test_vertices_scalar(self): aper = PolygonAperture((10.0, 20.0), SQUARE_OFFSETS) assert aper.vertices.shape == (4, 2) assert_allclose(aper.vertices, np.array([10.0, 20.0]) + SQUARE_OFFSETS) def test_vertex_offsets_normalized_to_ccw(self): cw = SQUARE_OFFSETS[::-1].copy() aper = PolygonAperture((0, 0), cw) assert _signed_polygon_area(aper.vertex_offsets) > 0 def test_invalid_vertex_offsets_quantity_raises(self): match = "'vertex_offsets' must not be a Quantity" with pytest.raises(TypeError, match=match): PolygonAperture((0, 0), SQUARE_OFFSETS * u.pix) def test_invalid_vertex_offsets_nonfinite_raises(self): bad = np.array([[0.0, 0.0], [np.nan, 1.0], [1.0, 0.0]]) match = "'vertex_offsets' must not contain any nonfinite values" with pytest.raises(ValueError, match=match): PolygonAperture((0, 0), bad) def test_accepts_nonconvex(self): """ The aperture supports nonconvex (L-shaped) polygons. """ l_shape = np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [2.0, 2.0], [2.0, 4.0], [0.0, 4.0]]) aper = PolygonAperture((0.0, 0.0), l_shape) assert aper.vertex_offsets.shape == (6, 2) assert_allclose(aper.area, 12.0) mask = aper.to_mask(method='exact') assert_allclose(mask.data.sum(), 12.0, atol=1e-12) def test_invalid_vertex_offsets_too_few_raises(self): match = ("'vertex_offsets' must define a polygon with at " 'least 3 vertices') with pytest.raises(ValueError, match=match): PolygonAperture((0, 0), [[0.0, 0.0], [1.0, 1.0]]) def test_invalid_vertex_offsets_unparseable(self): match = ("'vertex_offsets' must be convertible to a numeric " r'\(n_vertices, 2\) array') with pytest.raises(TypeError, match=match): PolygonAperture((0, 0), [['a', 'b'], ['c', 'd'], ['e', 'f']]) def test_from_vertices_round_trip(self): verts = np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 3.0]]) aper = PolygonAperture.from_vertices(verts) assert_allclose(aper.vertices, verts) def test_from_vertices_invalid_shape(self): match = r'vertices must have shape \(n_vertices, 2\)' with pytest.raises(ValueError, match=match): PolygonAperture.from_vertices(np.array([1.0, 2.0])) def test_from_vertices_rejects_degenerate(self): """ Test that ``from_vertices`` rejects collinear vertices. The ``from_vertices`` method must raise a clean ValueError rather than a ZeroDivisionError from the area-weighted centroid computation. """ match = ("'vertices' defines a degenerate polygon with zero " r'area \(vertices are collinear or coincident\)') with pytest.raises(ValueError, match=match): PolygonAperture.from_vertices([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) def test_offsets_reset_cached_properties(self): aper = PolygonAperture((0.0, 0.0), SQUARE_OFFSETS) _ = aper.vertices aper.vertex_offsets = TRIANGLE_OFFSETS assert aper.vertices.shape == (3, 2) class TestPolygonBoundingBox: """ Tests for the PolygonAperture minimal bounding box. """ def test_bbox_is_tight(self): """ Test that the bounding box of a polygon whose vertices are not centered on ``positions`` is the minimal box enclosing the polygon. """ aper = PolygonAperture.from_vertices([(1.0, 1.0), (9.0, 1.0), (9.0, 9.0)]) assert aper.bbox == BoundingBox(ixmin=1, ixmax=10, iymin=1, iymax=10) def test_bbox_offset(self): """ Test the bounding-box extents and center offset of an off-center polygon. """ # A triangle whose centroid is at (2, 1), giving vertex offsets # spanning x in [-2, 4] and y in [-1, 2] aper = PolygonAperture.from_vertices([(0.0, 0.0), (6.0, 0.0), (0.0, 3.0)]) assert_allclose(aper.positions, (2.0, 1.0)) assert_allclose(aper._xy_extents, (3.0, 1.5)) assert_allclose(aper._xy_bbox_offset, (1.0, 0.5)) assert aper.bbox == BoundingBox(ixmin=0, ixmax=7, iymin=0, iymax=4) def test_bbox_offset_zero_for_symmetric_polygon(self): aper = PolygonAperture((10.0, 20.0), SQUARE_OFFSETS) assert_allclose(aper._xy_bbox_offset, (0.0, 0.0)) assert aper.bbox == BoundingBox(ixmin=9, ixmax=12, iymin=19, iymax=22) def test_bbox_offset_resets_with_offsets(self): aper = PolygonAperture((0.0, 0.0), SQUARE_OFFSETS) assert_allclose(aper._xy_bbox_offset, (0.0, 0.0)) aper.vertex_offsets = TRIANGLE_OFFSETS assert_allclose(aper._xy_bbox_offset, (0.5 * (TRIANGLE_OFFSETS[:, 0].min() + TRIANGLE_OFFSETS[:, 0].max()), 0.5 * (TRIANGLE_OFFSETS[:, 1].min() + TRIANGLE_OFFSETS[:, 1].max()))) class TestPolygonMasks: """ Tests for the PolygonAperture aperture masks. """ def test_to_mask_exact_matches_rectangle(self): """ Test that exact polygon mask for a square equals the rectangular result. """ poly = PolygonAperture((5.0, 5.0), SQUARE_OFFSETS) rect = RectangularAperture((5.0, 5.0), w=2.0, h=2.0) assert_allclose(poly.to_mask(method='exact').data, rect.to_mask(method='exact').data) def test_to_mask_subpixel_approximates_exact(self): poly = PolygonAperture((5.5, 5.5), TRIANGLE_OFFSETS) exact = poly.to_mask(method='exact').data sub = poly.to_mask(method='subpixel', subpixels=32).data assert np.abs(exact - sub).max() < 0.05 def test_to_mask_center_method(self): poly = PolygonAperture((5.0, 5.0), SQUARE_OFFSETS) mask = poly.to_mask(method='center').data assert set(np.unique(mask)).issubset({0.0, 1.0}) def test_to_mask_center_matches_rectangle(self): """ Test that the ``center`` mask for a rectangle matches the equivalent polygon. A `RectangularAperture` and a `PolygonAperture` built from the same four corners must produce identical ``center`` masks. This includes pixel centers that lie exactly on the aperture boundary, which both apertures must exclude. """ shape = (12, 12) # Axis-aligned rectangle whose edges fall exactly on pixel centers # (x in [1, 5], y in [2, 4]), so many pixel centers lie on the # boundary and must be excluded by both apertures. rect = RectangularAperture((3.0, 3.0), w=4.0, h=2.0) corners = [(1.0, 2.0), (5.0, 2.0), (5.0, 4.0), (1.0, 4.0)] poly = PolygonAperture.from_vertices(corners) rect_mask = rect.to_mask(method='center').to_image(shape) poly_mask = poly.to_mask(method='center').to_image(shape) assert_allclose(poly_mask, rect_mask) # Boundary centers are excluded. Only the strictly interior centers # (x in {2, 3, 4}, y = 3) are counted. assert rect_mask.sum() == 3 # Rotated rectangle, compared against the equivalent polygon. theta = 0.6 cos_t, sin_t = np.cos(theta), np.sin(theta) half_w, half_h = 2.5, 1.5 local = [(-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)] abs_corners = [(6.0 + x * cos_t - y * sin_t, 6.0 + x * sin_t + y * cos_t) for x, y in local] rect2 = RectangularAperture((6.0, 6.0), w=5.0, h=3.0, theta=theta) poly2 = PolygonAperture.from_vertices(abs_corners) rect2_mask = rect2.to_mask(method='center').to_image(shape) poly2_mask = poly2.to_mask(method='center').to_image(shape) assert_allclose(poly2_mask, rect2_mask) def test_to_mask_array_returns_list(self): aper = PolygonAperture(POSITIONS, SQUARE_OFFSETS) masks = aper.to_mask(method='exact') assert isinstance(masks, list) assert len(masks) == len(POSITIONS) def test_invalid_mask_method(self): aper = PolygonAperture((0.0, 0.0), SQUARE_OFFSETS) match = "Invalid mask method: 'nonsense'" with pytest.raises(ValueError, match=match): aper.to_mask(method='nonsense') class TestPolygonPhotometry: """ Tests for photometry with a PolygonAperture, including pixel centers lying exactly on a polygon boundary. """ def test_photometry(self): data = np.ones((30, 30)) aper = PolygonAperture((10.0, 10.0), SQUARE_OFFSETS) phot = AperturePhotometry(data, aper, method='exact') assert_allclose(phot.flux, [4.0]) def test_photometry_subpixel_on_horizontal_boundary(self): """ Test that ``photometry`` excludes subpixel centers landing exactly on a horizontal polygon edge. This is the ``photometry`` counterpart of ``test_polygon_overlap_subpixel_on_horizontal_boundary`` in ``photutils/geometry/tests/test_polygon_overlap.py``, using a wide rectangle (half-widths 2 in x, 1.25 in y) and ``subpixels=2``. At an integer position the subpixel centers fall at offsets of +/-0.25, +/-0.75, +/-1.25, and so on from the aperture center. The horizontal edges at y = center +/- 1.25 coincide with a row of subpixel centers and are excluded, while the vertical edges at x = center +/- 2 fall between subpixel columns. The strictly interior subpixel centers are the 8 x-positions and 4 y-positions inside the open rectangle, giving 32 subpixels of area 1/4, i.e., 8. Were the on-boundary centers counted instead, the result would be 12. A power-of-two ``subpixels`` makes every subpixel center exactly representable, so the on-edge samples land exactly on the edges with any floating-point arithmetic. The 1/3-pixel steps of ``subpixels=3`` are inexact, and the x87 extended precision used by 32-bit x86 builds can move a nominally on-edge sample just off the edge. """ data = np.ones((31, 31)) offsets = np.array([[-2.0, -1.25], [2.0, -1.25], [2.0, 1.25], [-2.0, 1.25]]) aper = PolygonAperture((15.0, 15.0), offsets) phot = AperturePhotometry(data, aper, method='subpixel', subpixels=2) assert_allclose(phot.flux, [8.0]) def test_photometry_subpixel_on_vertical_boundary(self): """ Test that ``photometry`` excludes subpixel centers landing exactly on a vertical polygon edge. This is the ``photometry`` counterpart of ``test_polygon_overlap_subpixel_on_vertical_boundary``, using a tall rectangle (half-widths 1.25 in x, 2 in y) and ``subpixels=2`` (see ``test_photometry_subpixel_on_horizontal_boundary``). The vertical edges at x = center +/- 1.25 coincide with a column of subpixel centers and are excluded, giving 8 (the boundary-included value would be 12). """ data = np.ones((31, 31)) offsets = np.array([[-1.25, -2.0], [1.25, -2.0], [1.25, 2.0], [-1.25, 2.0]]) aper = PolygonAperture((15.0, 15.0), offsets) phot = AperturePhotometry(data, aper, method='subpixel', subpixels=2) assert_allclose(phot.flux, [8.0]) def test_photometry_subpixel_on_all_boundaries(self): """ Test that ``photometry`` excludes subpixel centers on horizontal edges, vertical edges, and at corners. This is the ``photometry`` counterpart of ``test_polygon_overlap_subpixel_on_all_boundaries``, using a square (half-widths 1.25 in both axes) and ``subpixels=2`` (see ``test_photometry_subpixel_on_horizontal_boundary``). At an integer position the four edges and four corners coincide with subpixel centers and are all excluded, leaving the 4 x-positions and 4 y-positions strictly inside the open square, i.e., 4 (the boundary-included value would be 9). """ data = np.ones((31, 31)) offsets = np.array([[-1.25, -1.25], [1.25, -1.25], [1.25, 1.25], [-1.25, 1.25]]) aper = PolygonAperture((15.0, 15.0), offsets) phot = AperturePhotometry(data, aper, method='subpixel', subpixels=2) assert_allclose(phot.flux, [4.0]) @pytest.mark.parametrize(('method', 'kwargs'), [('exact', {}), ('subpixel', {'subpixels': 7}), ('center', {})]) def test_array_photometry_matches_scalar(self, method, kwargs): """ Test that multiposition polygon photometry (batch Cython driver) matches the per-position mask-based (grid) result for the exact, subpixel, and center methods, including for a nonconvex polygon. This exercises the batch driver's per-position buffer reuse and confirms it is numerically identical to the ``polygon_overlap_grid`` code path used by ``to_mask``. """ rng = np.random.default_rng(0) data = rng.random((60, 60)) positions = [(15.0, 18.0), (30.0, 32.0), (45.0, 22.0)] offsets = _concave_star() aper = PolygonAperture(positions, offsets) phot = AperturePhotometry(data, aper, method=method, **kwargs) ref = [PolygonAperture(pos, offsets) .to_mask(method=method, **kwargs).multiply(data).sum() for pos in positions] assert_allclose(phot.flux, ref, rtol=1e-12) def test_matches_rotated_rectangle(self): """ Test that the exact polygon mask for a rotated rectangle matches the exact rectangular mask. This is a nontrivial test of the polygon masking machinery, since the rectangle vertices are not axis-aligned and thus require the full generality of the polygon code. """ rect = RectangularAperture((5.5, 5.5), w=4.0, h=2.0, theta=np.pi / 6) # Build the equivalent polygon from the rotated rectangle vertices. cos_t = np.cos(rect.theta.to_value(u.radian)) sin_t = np.sin(rect.theta.to_value(u.radian)) hw = rect.w / 2 hh = rect.h / 2 local = np.array([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]) rot = np.column_stack([local[:, 0] * cos_t - local[:, 1] * sin_t, local[:, 0] * sin_t + local[:, 1] * cos_t]) poly = PolygonAperture((5.5, 5.5), rot) assert_allclose(poly.to_mask(method='exact').data, rect.to_mask(method='exact').data) class TestPolygonIndexing: """ Tests for indexing, iteration, and plotting of a PolygonAperture. """ def test_indexing_and_iteration(self): aper = PolygonAperture(POSITIONS, SQUARE_OFFSETS) one = aper[1] assert one.isscalar assert_allclose(one.positions, POSITIONS[1]) assert_allclose(one.vertex_offsets, aper.vertex_offsets) assert list(aper)[0].isscalar sliced = aper[0:2] assert len(sliced) == 2 def test_indexing_scalar_raises(self): aper = PolygonAperture((0.0, 0.0), SQUARE_OFFSETS) match = "A scalar 'PolygonAperture' object cannot be indexed" with pytest.raises(TypeError, match=match): aper[0] match = r"A scalar 'PolygonAperture' object has no len\(\)" with pytest.raises(TypeError, match=match): len(aper) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_to_patch_scalar(self): import matplotlib.patches as mpatches aper = PolygonAperture((0.0, 0.0), SQUARE_OFFSETS) patch = aper._to_patch() assert isinstance(patch, mpatches.Polygon) class TestRegularPolygon: """ Tests for the regular-polygon constructors and properties. """ @pytest.mark.parametrize('n_vertices', [3, 4, 5, 6, 8, 12]) def test_from_regular_polygon(self, n_vertices): aper = PolygonAperture.from_regular_polygon((10.0, 20.0), n_vertices, 5.0) assert aper.vertex_offsets.shape == (n_vertices, 2) assert aper.n_vertices == n_vertices assert aper.is_regular assert_allclose(aper.outer_radius, 5.0) expected_inner_radius = 5.0 * np.cos(np.pi / n_vertices) assert_allclose(aper.inner_radius, expected_inner_radius) expected_side = 2.0 * 5.0 * np.sin(np.pi / n_vertices) assert_allclose(aper.side_length, expected_side) assert_quantity_allclose(aper.interior_angle, ((n_vertices - 2) / n_vertices) * 180.0 * u.deg) assert_quantity_allclose(aper.exterior_angle, (360.0 / n_vertices) * u.deg) assert_allclose(aper.vertices[0], (10.0, 25.0), atol=1e-12) def test_angle_quantity(self): aper = PolygonAperture.from_regular_polygon((0.0, 0.0), 4, 1.0, theta=45.0 * u.deg) assert aper.is_regular expected = np.array([[-np.sqrt(2) / 2, np.sqrt(2) / 2], [-np.sqrt(2) / 2, -np.sqrt(2) / 2], [np.sqrt(2) / 2, -np.sqrt(2) / 2], [np.sqrt(2) / 2, np.sqrt(2) / 2]]) assert_allclose(aper.vertex_offsets, expected, atol=1e-12) def test_angle_float(self): aper = PolygonAperture.from_regular_polygon((0.0, 0.0), 4, 1.0, theta=np.pi / 4) assert_allclose(aper.vertex_offsets[0], (-np.sqrt(2) / 2, np.sqrt(2) / 2), atol=1e-12) def test_invalid_n_vertices(self): match = 'n_vertices must be at least 3, got 2' with pytest.raises(ValueError, match=match): PolygonAperture.from_regular_polygon((0.0, 0.0), 2, 1.0) def test_invalid_radius(self): match = 'radius must be a finite positive number' with pytest.raises(ValueError, match=match): PolygonAperture.from_regular_polygon((0.0, 0.0), 4, 0.0) with pytest.raises(ValueError, match=match): PolygonAperture.from_regular_polygon((0.0, 0.0), 4, -1.0) with pytest.raises(ValueError, match=match): PolygonAperture.from_regular_polygon((0.0, 0.0), 4, np.nan) def test_is_regular_false_cases(self): aper = PolygonAperture.from_vertices( np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 3.0]])) assert not aper.is_regular aper2 = PolygonAperture((0.0, 0.0), np.array([[1.0, 2.0], [-1.0, 2.0], [-1.0, -2.0], [1.0, -2.0]])) assert not aper2.is_regular aper3 = PolygonAperture((0.0, 0.0), np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [2.0, 2.0], [2.0, 4.0], [0.0, 4.0]])) assert not aper3.is_regular def test_attrs_raise_when_not_regular(self): aper = PolygonAperture.from_vertices( np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 3.0]])) for attr in ('outer_radius', 'inner_radius', 'side_length', 'interior_angle', 'exterior_angle'): match = (f'{attr!r} is only defined for a regular polygon ' 'aperture') with pytest.raises(ValueError, match=match): getattr(aper, attr) def test_n_vertices(self): aper = PolygonAperture.from_vertices( np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 3.0]])) assert aper.n_vertices == 3 @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_matches_regions(self): from regions import PixCoord, RegularPolygonPixelRegion for n in (3, 5, 6, 8): for angle in (0.0, 30.0, 90.0): aper = PolygonAperture.from_regular_polygon( (10.0, 20.0), n, 5.0, theta=angle * u.deg) reg = RegularPolygonPixelRegion( PixCoord(10.0, 20.0), n, 5.0, angle=angle * u.deg) reg_xy = np.column_stack([reg.vertices.x, reg.vertices.y]) ours = aper.vertices assert _matches_up_to_cyclic_reversal(ours, reg_xy) @pytest.mark.parametrize('n_spikes', [5, 6, 7, 8]) def test_from_star(self, n_spikes): """ Test that ``_from_star`` constructs a star-shaped polygon with the correct area and that it raises for invalid parameters. """ xypos = (5, 5) match = 'n_spikes must be at least 2, got 1' with pytest.raises(ValueError, match=match): PolygonAperture._from_star(xypos, 1, 5.0, inner_radius=2.0) match = 'outer_radius must be a finite positive number, got -2.0' with pytest.raises(ValueError, match=match): PolygonAperture._from_star(xypos, 5, -2.0, inner_radius=2.0) match = 'inner_radius must be a finite positive number, got -2.0' with pytest.raises(ValueError, match=match): PolygonAperture._from_star(xypos, 5, 5.0, inner_radius=-2.0) match = (r'inner_radius must be less than outer_radius ' r'\(6.0 >= 5.0\)') with pytest.raises(ValueError, match=match): PolygonAperture._from_star(xypos, 5, 5.0, inner_radius=6.0) match = 'optimal_shape and collinear_edges cannot both be True' with pytest.raises(ValueError, match=match): PolygonAperture._from_star(xypos, 5, 5.0, inner_radius=2.0, optimal_shape=True, collinear_edges=True) match = 'collinear_edges requires n_spikes >= 5, got 4' with pytest.raises(ValueError, match=match): PolygonAperture._from_star(xypos, 4, 3.0, inner_radius=2.0, collinear_edges=True) match = ('inner_radius must be provided if both optimal_shape ' 'and collinear_edges are False') with pytest.raises(ValueError, match=match): PolygonAperture._from_star(xypos, 5, 3.0) data = np.ones((11, 11)) outer_radius = 5.0 inner_radius = 2.0 aper = PolygonAperture._from_star(xypos, n_spikes, outer_radius, inner_radius=inner_radius) phot = AperturePhotometry(data, aper, method='exact') assert_allclose(phot.flux, aper.area) aper1 = PolygonAperture._from_star(xypos, n_spikes, outer_radius, inner_radius=inner_radius, theta=10 * u.deg) phot = AperturePhotometry(data, aper1, method='exact') assert_allclose(phot.flux, aper1.area) aper2 = PolygonAperture._from_star(xypos, n_spikes, outer_radius, optimal_shape=True) phot = AperturePhotometry(data, aper2, method='exact') assert_allclose(phot.flux, aper2.area) aper3 = PolygonAperture._from_star(xypos, n_spikes, outer_radius, collinear_edges=True) phot = AperturePhotometry(data, aper3, method='exact') assert_allclose(phot.flux, aper3.area) class TestSkyPolygonConstruction: """ Tests for constructing a SkyPolygonAperture and validating its angular vertex offsets. """ def test_construct(self): pos = SkyCoord(ra=180.0, dec=0.0, unit='deg') offsets = TRIANGLE_OFFSETS * u.arcsec aper = SkyPolygonAperture(pos, offsets) assert aper.isscalar assert aper.vertex_offsets.unit.is_equivalent(u.arcsec) def test_offsets_not_quantity(self): pos = SkyCoord(ra=180.0, dec=0.0, unit='deg') match = ("'vertex_offsets' must be an angular Quantity with " r'shape \(n_vertices, 2\)') with pytest.raises(TypeError, match=match): SkyPolygonAperture(pos, TRIANGLE_OFFSETS) def test_offsets_wrong_unit(self): pos = SkyCoord(ra=180.0, dec=0.0, unit='deg') match = "'vertex_offsets' must have angular units" with pytest.raises(u.UnitsError, match=match): SkyPolygonAperture(pos, TRIANGLE_OFFSETS * u.m) def test_offsets_wrong_shape(self): pos = SkyCoord(ra=180.0, dec=0.0, unit='deg') match = r"'vertex_offsets' must have shape \(n_vertices, 2\)" with pytest.raises(ValueError, match=match): SkyPolygonAperture(pos, TRIANGLE_OFFSETS.flatten() * u.arcsec) def test_invalid_positions(self): match = "'positions' must be a SkyCoord instance" with pytest.raises(TypeError, match=match): SkyPolygonAperture((1.0, 2.0), TRIANGLE_OFFSETS * u.arcsec) def test_vertices_scalar(self): pos = SkyCoord(ra=180.0, dec=0.0, unit='deg') offsets = SQUARE_OFFSETS * u.arcsec aper = SkyPolygonAperture(pos, offsets) assert aper.vertices.shape == (4,) def test_vertices_array(self): pos = SkyCoord(ra=[180.0, 181.0], dec=[0.0, 1.0], unit='deg') offsets = SQUARE_OFFSETS * u.arcsec aper = SkyPolygonAperture(pos, offsets) assert aper.vertices.shape == (2, 4) def test_from_vertices_round_trip(self): verts = SkyCoord(ra=[180.0, 180.001, 180.0], dec=[0.0, 0.0, 0.001], unit='deg') aper = SkyPolygonAperture.from_vertices(verts) assert_quantity_allclose(aper.vertices.ra, verts.ra, atol=1e-9 * u.deg) assert_quantity_allclose(aper.vertices.dec, verts.dec, atol=1e-9 * u.deg) def test_from_vertices_requires_skycoord(self): match = 'vertices must be a one-dimensional SkyCoord' with pytest.raises(TypeError, match=match): SkyPolygonAperture.from_vertices(np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])) def test_from_vertices_too_few(self): verts = SkyCoord(ra=[180.0, 180.001], dec=[0.0, 0.001], unit='deg') match = "'vertices' must define a polygon with at least 3 vertices" with pytest.raises(ValueError, match=match): SkyPolygonAperture.from_vertices(verts) def test_indexing(self): pos = SkyCoord(ra=[180.0, 181.0], dec=[0.0, 1.0], unit='deg') offsets = SQUARE_OFFSETS * u.arcsec aper = SkyPolygonAperture(pos, offsets) one = aper[0] assert one.isscalar assert_quantity_allclose(one.positions.ra, 180.0 * u.deg) def test_offsets_reset_cached_properties(self): """ Test that changing vertex_offsets on a SkyPolygonAperture clears the cached vertices, which are stored as a SkyCoord. """ pos = SkyCoord(ra=180.0, dec=0.0, unit='deg') aper = SkyPolygonAperture(pos, SQUARE_OFFSETS * u.arcsec) _ = aper.vertices # cache it aper.vertex_offsets = TRIANGLE_OFFSETS * u.arcsec assert aper.vertices.shape == (3,) class TestSkyRegularPolygon: """ Tests for the sky regular-polygon constructors and properties. """ @pytest.mark.parametrize('n_vertices', [3, 4, 5, 6]) def test_from_regular_polygon(self, n_vertices): pos = SkyCoord(ra=10.0 * u.deg, dec=30.0 * u.deg) aper = SkyPolygonAperture.from_regular_polygon(pos, n_vertices, 2.0 * u.arcsec) assert aper.vertex_offsets.shape == (n_vertices, 2) assert aper.n_vertices == n_vertices assert aper.is_regular assert_quantity_allclose(aper.outer_radius, 2.0 * u.arcsec) assert_quantity_allclose(aper.inner_radius, 2.0 * np.cos(np.pi / n_vertices) * u.arcsec) assert_quantity_allclose(aper.side_length, 2.0 * 2.0 * np.sin(np.pi / n_vertices) * u.arcsec) assert_quantity_allclose(aper.interior_angle, ((n_vertices - 2) / n_vertices) * 180.0 * u.deg) assert_quantity_allclose(aper.exterior_angle, (360.0 / n_vertices) * u.deg) def test_angle(self): pos = SkyCoord(ra=10.0 * u.deg, dec=30.0 * u.deg) aper = SkyPolygonAperture.from_regular_polygon(pos, 4, 1.0 * u.arcmin, theta=45.0 * u.deg) assert aper.is_regular assert aper.vertex_offsets.unit == u.arcmin def test_invalid_n_vertices(self): pos = SkyCoord(ra=10.0 * u.deg, dec=30.0 * u.deg) match = 'n_vertices must be at least 3, got 2' with pytest.raises(ValueError, match=match): SkyPolygonAperture.from_regular_polygon(pos, 2, 1.0 * u.arcsec) def test_invalid_radius(self): pos = SkyCoord(ra=10.0 * u.deg, dec=30.0 * u.deg) match = 'radius must be an angular Quantity' with pytest.raises(TypeError, match=match): SkyPolygonAperture.from_regular_polygon(pos, 4, 1.0) with pytest.raises(TypeError, match=match): SkyPolygonAperture.from_regular_polygon(pos, 4, 1.0 * u.kg) match = 'radius must be a finite positive Quantity' with pytest.raises(ValueError, match=match): SkyPolygonAperture.from_regular_polygon(pos, 4, 0.0 * u.arcsec) with pytest.raises(ValueError, match=match): SkyPolygonAperture.from_regular_polygon(pos, 4, -1.0 * u.arcsec) def test_invalid_angle(self): pos = SkyCoord(ra=10.0 * u.deg, dec=30.0 * u.deg) match = 'theta must be an angular Quantity' with pytest.raises(TypeError, match=match): SkyPolygonAperture.from_regular_polygon(pos, 4, 1.0 * u.arcsec, theta=0.5) with pytest.raises(TypeError, match=match): SkyPolygonAperture.from_regular_polygon(pos, 4, 1.0 * u.arcsec, theta=1.0 * u.kg) def test_is_regular_false_and_attrs_raise(self): pos = SkyCoord(ra=10.0 * u.deg, dec=30.0 * u.deg) aper = SkyPolygonAperture(pos, np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 3.0]]) * u.arcsec) assert not aper.is_regular for attr in ('outer_radius', 'inner_radius', 'side_length', 'interior_angle', 'exterior_angle'): match = (f'{attr!r} is only defined for a regular polygon ' 'aperture') with pytest.raises(ValueError, match=match): getattr(aper, attr) def test_n_vertices(self): pos = SkyCoord(ra=10.0 * u.deg, dec=30.0 * u.deg) aper = SkyPolygonAperture(pos, np.array([[0.0, 0.0], [4.0, 0.0], [4.0, 3.0]]) * u.arcsec) assert aper.n_vertices == 3 class TestPolygonPixelSkyConversion: """ Tests for converting polygon apertures between pixel and sky coordinates. """ def test_pixel_sky_pixel(self): """ Test that round-trip through to_sky / to_pixel reproduces original. """ wcs = _make_wcs() aper = PolygonAperture([(50.0, 50.0), (60.0, 60.0)], SQUARE_OFFSETS) sky = aper.to_sky(wcs) back = sky.to_pixel(wcs) assert_allclose(back.positions, aper.positions, atol=1e-9) assert_allclose(back.vertex_offsets, aper.vertex_offsets, atol=1e-9) def test_pixel_to_sky_scalar(self): wcs = _make_wcs() aper = PolygonAperture((50.0, 50.0), SQUARE_OFFSETS) sky = aper.to_sky(wcs) assert sky.isscalar back = sky.to_pixel(wcs) assert_allclose(back.positions, aper.positions, atol=1e-9) def test_vertices_match_pixel_to_world(self): """ For a scalar polygon, the absolute sky vertices from ``to_sky`` match feeding the absolute pixel vertices directly to ``wcs.pixel_to_world``. The comparison is order-independent. The ``_make_wcs`` WCS flips parity, so the counterclockwise normalization applied when setting ``vertex_offsets`` may reverse the vertex order relative to the input. The two sets of vertices must still describe the same polygon. """ wcs = _make_wcs() aper = PolygonAperture((50.0, 50.0), SQUARE_OFFSETS) abs_pix = aper.vertices direct = wcs.pixel_to_world(abs_pix[:, 0], abs_pix[:, 1]) got = aper.to_sky(wcs).vertices # Each direct vertex must coincide with one of the to_sky vertices nearest = [np.min(coord.separation(got).to_value(u.arcsec)) for coord in direct] assert_allclose(nearest, 0.0, atol=1e-9) def test_sky_pixel_sky(self): """ Test that round-trip through to_pixel / to_sky reproduces the original sky aperture. """ wcs = _make_wcs() positions = SkyCoord(ra=[180.0, 180.01], dec=[0.0, 0.01], unit='deg') aper = SkyPolygonAperture(positions, SQUARE_OFFSETS * u.arcsec) pixel = aper.to_pixel(wcs) back = pixel.to_sky(wcs) assert_quantity_allclose(back.positions.ra, aper.positions.ra, atol=1e-9 * u.deg) assert_quantity_allclose(back.positions.dec, aper.positions.dec, atol=1e-9 * u.deg) assert_quantity_allclose(back.vertex_offsets, aper.vertex_offsets, atol=1e-6 * u.arcsec) astropy-photutils-24be529/photutils/aperture/tests/test_positional_kwargs.py000066400000000000000000000213371526452531300300120ustar00rootroot00000000000000# 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 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): aper.to_mask('exact') 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): aperture_photometry(DATA, aper, error) astropy-photutils-24be529/photutils/aperture/tests/test_rectangle.py000066400000000000000000000357551526452531300262300ustar00rootroot00000000000000# 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 numpy.testing import assert_allclose from photutils.aperture import PolygonAperture, SkyPolygonAperture from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, RectangularMaskMixin, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.tests.test_aperture_common import ( BaseTestAnnulusMutation, BaseTestAperture, BaseTestRotatedPixelAperture) 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(BaseTestRotatedPixelAperture): aperture = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=np.pi / 2.0) copy_param = 'w' copy_value = 20.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) class TestRectangularAnnulus(BaseTestAnnulusMutation, BaseTestRotatedPixelAperture): aperture = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=np.pi / 3) copy_param = 'w_in' copy_value = 2.0 @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 a positive scalar" 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_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) def test_w_in_greater_than_w_out(self): """ Test that a ValueError is raised when w_in >= w_out. """ match = "'w_out' must be greater than 'w_in'" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=8.0, h_out=5.0, theta=np.pi / 3) class TestSkyRectangularAperture(BaseTestAperture): aperture = SkyRectangularAperture(SKYCOORD, w=10.0 * UNIT, h=5.0 * UNIT, theta=30 * u.deg) copy_param = 'w' copy_value = 20.0 * UNIT @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) class TestSkyRectangularAnnulus(BaseTestAnnulusMutation, BaseTestAperture): aperture = SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) copy_param = 'w_in' copy_value = 2.0 * UNIT @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 zero" 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_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_w_in_greater_than_w_out(self): """ Test that a ValueError is raised when w_in >= w_out. """ match = "'w_out' must be greater than 'w_in'" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=8.0 * UNIT, h_out=5.0 * UNIT, theta=60 * u.deg) class TestThetaQuantity: """ Tests for a theta input as an angular Quantity. """ def test_aperture(self): 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_annulus(self): 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) class TestDeprecatedMaskMixin: """ Tests for the deprecated RectangularMaskMixin. """ @pytest.mark.parametrize(('method', 'subpixels'), [('exact', 5), ('center', 5), ('subpixel', 8)]) def test_to_mask(self, method, subpixels): """ Test that the deprecated RectangularMaskMixin.to_mask method works with the current _translate_mask_method signature and matches the nondeprecated to_mask result. """ aper = RectangularAperture((10.0, 10.0), w=4.0, h=2.0, theta=0.5) mask = RectangularMaskMixin.to_mask(aper, method=method, subpixels=subpixels) expected = aper.to_mask(method=method, subpixels=subpixels) assert_quantity_allclose(mask.data, expected.data) class TestToPolygon: """ Tests for converting rectangular apertures to polygon apertures. """ def test_pixel(self): aper = RectangularAperture((10.0, 20.0), w=4.0, h=2.0) poly = aper.to_polygon() assert isinstance(poly, PolygonAperture) assert poly.n_vertices == 4 # Area is exact (rectangle is itself a polygon) assert_allclose(poly.area, aper.area) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_pixel_rotation(self, theta_deg): aper = RectangularAperture((10.0, 20.0), w=4.0, h=2.0, theta=np.deg2rad(theta_deg)) poly = aper.to_polygon() assert poly.n_vertices == 4 assert_allclose(poly.area, aper.area, rtol=1e-12) # Bounding-box extents should match the rotated rectangle's. expected_x_extent = (0.5 * abs(aper.w * np.cos(aper.theta.value)) + 0.5 * abs(aper.h * np.sin(aper.theta.value))) expected_y_extent = (0.5 * abs(aper.w * np.sin(aper.theta.value)) + 0.5 * abs(aper.h * np.cos(aper.theta.value))) assert_allclose(poly._xy_extents[0], expected_x_extent, rtol=1e-12) assert_allclose(poly._xy_extents[1], expected_y_extent, rtol=1e-12) def test_pixel_multi_position(self): aper = RectangularAperture([(10.0, 20.0), (30.0, 40.0)], w=4.0, h=3.0, theta=0.5) poly = aper.to_polygon() assert poly.vertices.shape == (2, 4, 2) def test_sky(self): pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyRectangularAperture(pos, w=4.0 * u.arcsec, h=2.0 * u.arcsec) poly = aper.to_polygon() assert isinstance(poly, SkyPolygonAperture) assert poly.n_vertices == 4 assert poly.vertices.shape == (4,) # At theta=0, the width (4) is along +lat (d_lat) and the height (2) # is along +lon (d_lon). offsets = poly.vertex_offsets.to_value(u.arcsec) assert_allclose(np.sort(np.abs(offsets[:, 0])), [1.0, 1.0, 1.0, 1.0]) assert_allclose(np.sort(np.abs(offsets[:, 1])), [2.0, 2.0, 2.0, 2.0]) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_sky_rotation(self, theta_deg): pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyRectangularAperture(pos, w=4.0 * u.arcsec, h=2.0 * u.arcsec, theta=theta_deg * u.deg) poly = aper.to_polygon() assert poly.n_vertices == 4 # The perimeter and vertex distances are rotation-invariant. assert_allclose(poly.perimeter.to_value(u.arcsec), 2 * (4.0 + 2.0)) radii = np.hypot(*poly.vertex_offsets.to_value(u.arcsec).T) assert_allclose(radii, np.hypot(1.0, 2.0)) def test_sky_multi_position(self): pos = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') aper = SkyRectangularAperture(pos, w=4.0 * u.arcsec, h=3.0 * u.arcsec, theta=0.5 * u.rad) poly = aper.to_polygon() assert poly.vertices.shape == (2, 4) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_sky_wcs_round_trip(self, theta_deg, tan_wcs): """ Test that converting a SkyRectangularAperture to a polygon and then to pixels matches converting to pixels and then to a polygon for all rotation angles. The two pixel polygons share the same area and bounding-box extents. """ pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyRectangularAperture(pos, w=4.0 * u.arcsec, h=2.0 * u.arcsec, theta=theta_deg * u.deg) poly_from_sky = aper.to_polygon().to_pixel(tan_wcs) poly_from_pix = aper.to_pixel(tan_wcs).to_polygon() assert_allclose(poly_from_sky.area, poly_from_pix.area, rtol=1e-6) assert_allclose(poly_from_sky._xy_extents, poly_from_pix._xy_extents, rtol=1e-6) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_sky_polygon_orientation(self, theta_deg, tan_wcs): """ Test that the sky polygon covers the same pixels as the sky rectangle, which requires the same rotation sense. """ pos = SkyCoord(ra=10.0, dec=30.0, unit='deg') aper = SkyRectangularAperture(pos, w=40.0 * u.arcsec, h=10.0 * u.arcsec, theta=theta_deg * u.deg) shape = (101, 101) mask_aper = aper.to_pixel(tan_wcs).to_mask(method='exact') mask_poly = aper.to_polygon().to_pixel(tan_wcs).to_mask( method='exact') assert_allclose(mask_poly.to_image(shape), mask_aper.to_image(shape), atol=1e-4) @pytest.mark.parametrize('theta_deg', [0.0, 30.0, 45.0, 90.0, 137.0]) def test_pixel_to_sky_round_trip(self, theta_deg, tan_wcs): """ Test that converting a RectangularAperture to a polygon and then to sky matches converting to sky and then to a polygon for all rotation angles. """ # The bounding-box orientation can differ at the ~1e-5 level because # the sky shape parameters from ``to_sky`` are derived from a local # linear (SVD) approximation, while the polygon ``to_sky`` maps # each vertex exactly, so only rotation-invariant quantities are # compared. aper = RectangularAperture((50.0, 50.0), w=4.0, h=2.0, theta=np.deg2rad(theta_deg)) sky_from_poly = aper.to_polygon().to_sky(tan_wcs) sky_from_aper = aper.to_sky(tan_wcs).to_polygon() assert_allclose(sky_from_poly.positions.ra.deg, sky_from_aper.positions.ra.deg) assert_allclose(sky_from_poly.positions.dec.deg, sky_from_aper.positions.dec.deg) assert_allclose(sky_from_poly.perimeter.to_value(u.arcsec), sky_from_aper.perimeter.to_value(u.arcsec), rtol=1e-6) assert_allclose(sky_from_poly.to_pixel(tan_wcs).area, sky_from_aper.to_pixel(tan_wcs).area, rtol=1e-6) astropy-photutils-24be529/photutils/aperture/tests/test_region_converters.py000066400000000000000000000637511526452531300300160ustar00rootroot00000000000000# 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, PolygonAperture, RectangularAnnulus, RectangularAperture, SkyCircularAnnulus, SkyCircularAperture, SkyEllipticalAnnulus, SkyEllipticalAperture, SkyPolygonAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.region_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) class TestRegionApertureRoundTrip: """ Round-trip conversions between regions and apertures for each supported shape. """ @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_circle(self, 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) # Check that nonscalar center and radius raise ValueError 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_ellipse(self, 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) # Check that nonscalar center, width, height, and angle raise # ValueError 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'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=[1, 16], height=10, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=match): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=16, height=[1, 10], angle=Angle(30, 'deg'), ) match = "'angle' must be a scalar" with pytest.raises(ValueError, match=match): 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_rectangle(self, 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) # Check that nonscalar center, width, height, and angle raise # ValueError 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_circle_annulus(self, 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) # Check that nonscalar center, inner_radius, and outer_radius raise # ValueError 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, ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): CircleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_radius=[1, 5], outer_radius=8, ) with pytest.raises(ValueError, match=match): 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_ellipse_annulus(self, 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) # Check that nonscalar center, inner_width, inner_height, # outer_width, outer_height, and angle raise ValueError 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'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): 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=match): 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'), ) match = "'angle' must be a scalar" with pytest.raises(ValueError, match=match): 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_rectangle_annulus(self, 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) # Check that nonscalar center, inner_width, inner_height, # outer_width, outer_height, and angle raise ValueError 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'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): 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=match): 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'), ) match = "'angle' must be a scalar" with pytest.raises(ValueError, match=match): 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_polygon(self, image_2d_wcs): from regions import PixCoord, PolygonPixelRegion # Counterclockwise simple (non-self-intersecting) quadrilateral. x = [42, 50, 47, 40] y = [43, 45, 52, 50] region_shape = PolygonPixelRegion(vertices=PixCoord(x=x, y=y)) aperture = region_to_aperture(region_shape) assert isinstance(aperture, PolygonAperture) ref_vertices = np.column_stack((region_shape.vertices.x, region_shape.vertices.y)) assert_allclose(aperture.vertices, ref_vertices) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyPolygonAperture) # The inverted RA axis flips the polygon orientation, so the # counterclockwise normalization reverses the vertex order. Compare # the vertices independently of their ordering. ap_verts = np.column_stack((aperture_sky.vertices.ra.deg, aperture_sky.vertices.dec.deg)) reg_verts = np.column_stack((region_sky.vertices.ra.deg, region_sky.vertices.dec.deg)) ap_sorted = ap_verts[np.lexsort((ap_verts[:, 1], ap_verts[:, 0]))] reg_sorted = reg_verts[np.lexsort((reg_verts[:, 1], reg_verts[:, 0]))] assert_allclose(ap_sorted, reg_sorted, atol=1e-9) class TestApertureToRegion: """ Tests for the aperture_to_region function. """ @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_aperture_to_region(self): 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 # A square polygon centered on each position (zero-centroid offsets # so the pixel round-trip is exact). poly_offsets = np.array([(-5.0, -5.0), (5.0, -5.0), (5.0, 5.0), (-5.0, 5.0)]) theta = np.linspace(0.0, 2 * np.pi, 5, endpoint=False) sky_poly_offsets = np.column_stack([3.0 * np.cos(theta), 3.0 * np.sin(theta)]) * unit 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), PolygonAperture(xypos, poly_offsets), SkyPolygonAperture(skycoord, sky_poly_offsets), ] 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) if isinstance(aperture, SkyPolygonAperture): # The sky polygon round-trip recomputes the centroid from # the vertices, so positions and offsets match only to # within floating-point tolerance. assert isinstance(aper0, SkyPolygonAperture) sep = aper0.positions.separation(aperture[0].positions) assert_quantity_allclose(sep, 0 * u.deg, atol=1e-9 * u.arcsec) assert_quantity_allclose(aper0.vertex_offsets, aperture[0].vertex_offsets, atol=1e-9 * u.arcsec) else: assert aper0 == aperture[0] @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_unknown_type(self): """ 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 scalar aperture that passes the scalar check but has no matching isinstance branch. """ isscalar = True match = 'Cannot convert input aperture to a Region object' with pytest.raises(TypeError, match=match): _scalar_aperture_to_region(_FakeAperture()) class TestShapelyConversions: """ Tests for converting Shapely polygons to regions. """ @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_polygon_to_region(self): 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_multipolygon_to_region(self): """ 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 class TestInvalidInputs: """ Tests for the converter input validation. """ @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_invalid_inputs(self): 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) astropy-photutils-24be529/photutils/aperture/tests/test_segmentation_masking.py000066400000000000000000000555331526452531300304660ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for segmentation-based masking of aperture photometry, shared by `AperturePhotometry` and `ApertureStats`. """ import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from photutils.aperture._batch_photometry import (SHAPE_CIRCLE, batch_aperture_sums) from photutils.aperture._segmentation import (make_segmentation_exclusion, process_segmentation_inputs) from photutils.aperture.circle import CircularAperture from photutils.aperture.photometry import AperturePhotometry from photutils.aperture.stats import ApertureStats from photutils.aperture.tests.conftest import (NoBatchCircularAperture, make_scene) from photutils.segmentation import SegmentationImage class TestProcessSegmentationInputs: def test_method_none_returns_none(self): data, segm = make_scene() positions = [(21, 21)] result = process_segmentation_inputs(SegmentationImage(segm), None, 'none', positions, data.shape) assert result == (None, None) def test_invalid_method(self): data, segm = make_scene() match = 'mask_method must be one of' with pytest.raises(ValueError, match=match): process_segmentation_inputs(SegmentationImage(segm), None, 'invalid', [(21, 21)], data.shape) def test_missing_segmentation(self): match = 'segmentation_image must be input' with pytest.raises(ValueError, match=match): process_segmentation_inputs(None, None, 'mask', [(21, 21)], (50, 50)) def test_segmentation_image_object(self): data, segm = make_scene() segm_obj = SegmentationImage(segm) out_segm, out_labels = process_segmentation_inputs( segm_obj, [1], 'mask', [(21, 21)], data.shape) assert out_segm.dtype == np.intp assert_allclose(out_labels, [1]) def test_array_input_rejected(self): data, segm = make_scene() match = 'segmentation_image must be a SegmentationImage' with pytest.raises(TypeError, match=match): process_segmentation_inputs(segm, [1], 'mask', [(21, 21)], data.shape) def test_wrong_shape(self): segm = SegmentationImage(np.zeros((10, 10), dtype=int)) match = 'same shape as the data' with pytest.raises(ValueError, match=match): process_segmentation_inputs(segm, [1], 'mask', [(1, 1)], (50, 50)) def test_labels_length_mismatch(self): data, segm = make_scene() match = 'labels must have the same length' with pytest.raises(ValueError, match=match): process_segmentation_inputs(SegmentationImage(segm), [1, 2], 'mask', [(21, 21)], data.shape) @pytest.mark.parametrize(('labels', 'match'), [ ([1, 2], 'labels must have the same length'), ([[1]], 'labels must be a 1D array'), ]) def test_labels_shape_without_masking(self, labels, match): """ Test that the shape of the labels is validated when they are not used. """ data, _ = make_scene() with pytest.raises(ValueError, match=match): process_segmentation_inputs(None, labels, 'none', [(21, 21)], data.shape) aper = CircularAperture((21, 21), r=3) with pytest.raises(ValueError, match=match): ApertureStats(data, aper, labels=labels) assert process_segmentation_inputs( None, [1], 'none', [(21, 21)], data.shape) == (None, None) def test_labels_not_1d(self): data, segm = make_scene() match = 'labels must be a 1D array' with pytest.raises(ValueError, match=match): process_segmentation_inputs(SegmentationImage(segm), [[1, 2]], 'mask', [(21, 21)], data.shape) def test_labels_required(self): data, segm = make_scene() match = 'labels must be input when segmentation_image is input' with pytest.raises(ValueError, match=match): process_segmentation_inputs(SegmentationImage(segm), None, 'mask', [(21, 21), (28, 22)], data.shape) def test_label_not_in_image(self): data, segm = make_scene() segm_in = SegmentationImage(segm) match = r'labels \[3\] are not present in the segmentation_image' with pytest.raises(ValueError, match=match): process_segmentation_inputs(segm_in, [1, 3], 'mask', [(21, 21), (28, 22)], data.shape) def test_invalid_labels_reported_once_and_sorted(self): data, segm = make_scene() match = r'labels \[-1, 5\] are not present' with pytest.raises(ValueError, match=match): process_segmentation_inputs(SegmentationImage(segm), [5, 1, -1, 5], 'mask', [(21, 21)] * 4, data.shape) @pytest.mark.parametrize('method', ['mask', 'source_only', 'correct']) def test_label_zero_allowed(self, method): # Label 0 disables the masking for that aperture and is not # required to be present in the image. data, segm = make_scene() _, out_labels = process_segmentation_inputs( SegmentationImage(segm), [0, 1], method, [(21, 21), (28, 22)], data.shape) assert_array_equal(out_labels, [0, 1]) def test_background_only_labels_optional(self): data, segm = make_scene() out_segm, out_labels = process_segmentation_inputs( SegmentationImage(segm), None, 'background_only', [(21, 21), (28, 22)], data.shape) assert out_segm.dtype == np.intp assert out_labels.dtype == np.intp assert_array_equal(out_labels, [0, 0]) def test_background_only_ignores_labels(self): # The labels are not used, so they need not be present in the # segmentation image. data, segm = make_scene() _, out_labels = process_segmentation_inputs( SegmentationImage(segm), [3], 'background_only', [(21, 21)], data.shape) assert_array_equal(out_labels, [0]) @pytest.mark.parametrize( ('labels', 'match'), [([[1, 2]], 'labels must be a 1D array'), ([1, 2, 3], 'labels must have the same length')]) def test_background_only_labels_shape_validated(self, labels, match): # The labels are not used, but their shape is still validated # because they are stored and sliced per aperture. data, segm = make_scene() with pytest.raises(ValueError, match=match): process_segmentation_inputs(SegmentationImage(segm), labels, 'background_only', [(21, 21)], data.shape) class TestAperturePhotometry: def test_batch_matches_mask_path(self): """ Test that the batch driver for circular apertures matches the Python mask code path for segmentation masking. """ data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) labels = [1, 2] result = AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=labels, mask_method='mask') for idx, label in enumerate(labels): manual_mask = (segm > 0) & (segm != label) ref = AperturePhotometry( data, CircularAperture(aper.positions[idx], r=6), mask=manual_mask) assert_allclose(result.flux[idx], ref.flux) def test_label_not_in_image(self): data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) match = 'not present in the segmentation_image' with pytest.raises(ValueError, match=match): AperturePhotometry(data, aper, segmentation_image=SegmentationImage(segm), labels=[1, 3], mask_method='mask') def test_background_only_matches_manual(self): """ Test that 'background_only' excludes every labeled pixel, regardless of the (optional) labels. """ data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) ref = AperturePhotometry(data, aper, mask=segm > 0) segm = SegmentationImage(segm) for labels in (None, [1, 2], [0, 0]): result = AperturePhotometry(data, aper, segmentation_image=segm, labels=labels, mask_method='background_only') assert_allclose(result.flux, ref.flux) assert_allclose(result.area, ref.area) def test_background_only_mask_path_parity(self): """ Test that the batch driver and the Python mask path agree for 'background_only', including the flags. """ data, segm = make_scene() error = np.full(data.shape, 0.5) positions = [(21, 21), (28, 22), (5, 5)] kwargs = {'error': error, 'segmentation_image': SegmentationImage(segm), 'mask_method': 'background_only'} batch = AperturePhotometry(data, CircularAperture(positions, r=6), **kwargs) nobatch = AperturePhotometry( data, NoBatchCircularAperture(positions, r=6), **kwargs) assert_allclose(batch.flux, nobatch.flux, rtol=1e-12) assert_allclose(batch.flux_err, nobatch.flux_err, rtol=1e-12) assert_allclose(batch.area, nobatch.area, rtol=1e-12) assert_array_equal(batch.flags, nobatch.flags) class TestApertureStats: @pytest.mark.parametrize('method', ['none', 'mask', 'source_only', 'background_only', 'correct']) def test_matches_aperture_photometry(self, method): data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) kwargs = {} if method != 'none': kwargs = {'segmentation_image': SegmentationImage(segm), 'labels': [1, 2], 'mask_method': method} phot = AperturePhotometry(data, aper, **kwargs) stats = ApertureStats(data, aper, **kwargs) assert_allclose(stats.sum, phot.flux, rtol=1e-10) def test_slicing_preserves_labels(self): data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) stats = ApertureStats(data, aper, segmentation_image=SegmentationImage(segm), labels=[1, 2], mask_method='mask') sub = stats[1] assert_allclose(sub.sum, stats.sum[1]) def test_copy_preserves_masking(self): data, segm = make_scene() aper = CircularAperture([(21, 21)], r=6) stats = ApertureStats(data, aper, segmentation_image=SegmentationImage(segm), labels=[1], mask_method='mask') copied = stats.copy() assert_allclose(copied.sum, stats.sum) def test_no_segmentation_slicing(self): data, _ = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) stats = ApertureStats(data, aper) sub = stats[0] assert sub._seg_labels is None def test_label_not_in_image(self): data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) match = 'not present in the segmentation_image' with pytest.raises(ValueError, match=match): ApertureStats(data, aper, segmentation_image=SegmentationImage(segm), labels=[1, 3], mask_method='mask') def test_background_only_without_labels(self): data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) stats = ApertureStats(data, aper, segmentation_image=SegmentationImage(segm), mask_method='background_only') ref = ApertureStats(data, aper, mask=segm > 0) assert stats.labels is None assert_allclose(stats.sum, ref.sum) assert_allclose(stats.median, ref.median) sub = stats[1] assert sub.labels is None assert_allclose(sub.sum, stats.sum[1]) def test_background_only_with_labels(self): # Input labels are echoed and sliced even though the method # does not use them. data, segm = make_scene() aper = CircularAperture([(21, 21), (28, 22)], r=6) stats = ApertureStats(data, aper, segmentation_image=SegmentationImage(segm), labels=[1, 2], mask_method='background_only') ref = ApertureStats(data, aper, mask=segm > 0) assert_allclose(stats.sum, ref.sum) sub = stats[1] assert sub.labels == 2 assert_allclose(sub.sum, stats.sum[1]) match = 'labels must have the same length' with pytest.raises(ValueError, match=match): ApertureStats(data, aper, segmentation_image=SegmentationImage(segm), labels=[1, 2, 3], mask_method='background_only') class TestMakeSegmentationExclusion: def test_none_method(self): segm = np.array([[0, 1], [2, 1]]) _, _, exclude, affected = make_segmentation_exclusion('none', segm, 1) assert not exclude.any() assert not affected.any() def test_label_zero(self): segm = np.array([[0, 1], [2, 1]]) _, _, exclude, affected = make_segmentation_exclusion('mask', segm, 0) assert not exclude.any() assert not affected.any() def test_mask_method(self): segm = np.array([[0, 1], [2, 1]]) _, _, exclude, affected = make_segmentation_exclusion('mask', segm, 1) expected = np.array([[False, False], [True, False]]) assert_array_equal(exclude, expected) assert_array_equal(affected, expected) def test_source_only_method(self): segm = np.array([[0, 1], [2, 1]]) _, _, exclude, affected = make_segmentation_exclusion( 'source_only', segm, 1) expected = np.array([[True, False], [True, False]]) assert_array_equal(exclude, expected) # Background exclusions are not marked as affected expected_affected = np.array([[False, False], [True, False]]) assert_array_equal(affected, expected_affected) @pytest.mark.parametrize('label', [0, 1]) def test_background_only_method(self, label): # Every labeled pixel is excluded and marked as affected. The # label is ignored, so label 0 does not disable the masking. segm = np.array([[0, 1], [2, 1]]) _, _, exclude, affected = make_segmentation_exclusion( 'background_only', segm, label) expected = np.array([[False, True], [True, True]]) assert_array_equal(exclude, expected) assert_array_equal(affected, expected) def test_correct_replaces_neighbor(self): # 5x5 cutout, center (2, 2). A neighbor pixel at (1, 2) [x=1, y=2] # is mirrored from (3, 2) [x=3, y=2], a good background pixel. segm = np.zeros((5, 5), dtype=int) segm[2, 2] = 1 # target center segm[2, 1] = 2 # neighbor at x=1, y=2 data = np.arange(25, dtype=float).reshape(5, 5) out_data, _, exclude, affected = make_segmentation_exclusion( 'correct', segm, 1, data=data, cutout_xycen=(2, 2)) # Neighbor replaced, not excluded assert not exclude[2, 1] # Replaced pixels are marked as affected assert affected[2, 1] # Mirror of (x=1, y=2) is (x=3, y=2) -> data[2, 3] assert out_data[2, 1] == data[2, 3] def test_correct_out_of_bounds_mirror(self): # Neighbor whose mirror falls outside the cutout is excluded. segm2 = np.zeros((5, 5), dtype=int) segm2[1, 1] = 1 segm2[1, 0] = 2 # neighbor x=0,y=1 with mirror x=2*1-0=2 inside segm2[3, 3] = 2 # neighbor x=3,y=3 with mirror x=2*1-3=-1 outside data = np.ones((5, 5)) _, _, exclude, affected = make_segmentation_exclusion( 'correct', segm2, 1, data=data, cutout_xycen=(1, 1)) assert exclude[3, 3] assert affected[3, 3] def test_correct_neighbor_mirror(self): # A neighbor whose mirror is also a neighbor must be excluded. segm = np.zeros((5, 5), dtype=int) segm[2, 2] = 1 segm[2, 1] = 2 # neighbor x=1,y=2 with mirror x=3,y=2 segm[2, 3] = 2 # neighbor x=3,y=2 with mirror x=1,y=2 (a neighbor) data = np.ones((5, 5)) _, _, exclude, _ = make_segmentation_exclusion( 'correct', segm, 1, data=data, cutout_xycen=(2, 2)) assert exclude[2, 1] assert exclude[2, 3] def test_correct_masked_mirror(self): # A neighbor whose mirror is in base_mask must be excluded. segm = np.zeros((5, 5), dtype=int) segm[2, 2] = 1 segm[2, 1] = 2 # neighbor x=1,y=2 with mirror x=3,y=2 base_mask = np.zeros((5, 5), dtype=bool) base_mask[2, 3] = True # mask the mirror pixel data = np.ones((5, 5)) _, _, exclude, _ = make_segmentation_exclusion( 'correct', segm, 1, data=data, base_mask=base_mask, cutout_xycen=(2, 2)) assert exclude[2, 1] def test_correct_with_error(self): segm = np.zeros((5, 5), dtype=int) segm[2, 2] = 1 segm[2, 1] = 2 data = np.arange(25, dtype=float).reshape(5, 5) error = np.arange(25, dtype=float).reshape(5, 5) * 0.1 _, out_error, _, _ = make_segmentation_exclusion( 'correct', segm, 1, data=data, error=error, cutout_xycen=(2, 2)) assert out_error[2, 1] == error[2, 3] class TestBatchDriverSegmentation: def test_mask_method(self): rng = np.random.default_rng(0) data = rng.random((40, 40)) error = rng.random((40, 40)) + 0.1 mask = np.zeros((40, 40), dtype=np.uint8) positions = np.array([[20.0, 20.0]]) params = np.array([8.0]) segm = np.zeros((40, 40), dtype=np.intp) segm[18:23, 18:23] = 1 segm[18:23, 23:28] = 2 labels = np.array([1], dtype=np.intp) sums = batch_aperture_sums( data, error, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8, segm, labels, 1)[0] # Reference via global mask manual_mask = ((segm > 0) & (segm != 1)).astype(np.uint8) ref = batch_aperture_sums( data, error, manual_mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8)[0] assert_allclose(sums, ref) def test_source_only_method(self): rng = np.random.default_rng(1) data = rng.random((40, 40)) error = rng.random((40, 40)) + 0.1 mask = np.zeros((40, 40), dtype=np.uint8) positions = np.array([[20.0, 20.0]]) params = np.array([8.0]) segm = np.zeros((40, 40), dtype=np.intp) segm[18:23, 18:23] = 1 labels = np.array([1], dtype=np.intp) sums = batch_aperture_sums( data, error, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8, segm, labels, 2)[0] manual_mask = (segm != 1).astype(np.uint8) ref = batch_aperture_sums( data, error, manual_mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8)[0] assert_allclose(sums, ref) @pytest.mark.parametrize('label', [0, 1]) def test_background_only_method(self, label): # Method 3 excludes every labeled pixel. The label is ignored, # so label 0 does not disable the masking. rng = np.random.default_rng(4) data = rng.random((40, 40)) error = rng.random((40, 40)) + 0.1 mask = np.zeros((40, 40), dtype=np.uint8) positions = np.array([[20.0, 20.0]]) params = np.array([8.0]) segm = np.zeros((40, 40), dtype=np.intp) segm[18:23, 18:23] = 1 segm[18:23, 23:28] = 2 labels = np.array([label], dtype=np.intp) sums = batch_aperture_sums( data, error, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8, segm, labels, 3)[0] manual_mask = (segm > 0).astype(np.uint8) ref = batch_aperture_sums( data, error, manual_mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8)[0] assert_allclose(sums, ref) def test_label0_disables(self): rng = np.random.default_rng(2) data = rng.random((40, 40)) error = rng.random((40, 40)) + 0.1 mask = np.zeros((40, 40), dtype=np.uint8) positions = np.array([[20.0, 20.0]]) params = np.array([8.0]) segm = np.zeros((40, 40), dtype=np.intp) segm[18:23, 18:23] = 1 segm[18:23, 23:28] = 2 labels = np.array([0], dtype=np.intp) sums = batch_aperture_sums( data, error, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8, segm, labels, 1)[0] ref = batch_aperture_sums( data, error, mask, positions, SHAPE_CIRCLE, params, 8.0, 8.0, 0.0, 0.0, 1, 8)[0] assert_allclose(sums, ref) def test_correct_method_matches_mask_path(self): # The batch 'correct' kernel (seg_method=4) must exactly match # the Python mask-path 'correct' implementation. rng = np.random.default_rng(3) data = rng.normal(10.0, 1.0, (60, 60)) error = rng.random((60, 60)) + 0.5 mask = np.zeros((60, 60), dtype=bool) mask[22, 30] = True segm = np.zeros((60, 60), dtype=np.intp) segm[18:25, 18:25] = 1 # target segm[18:25, 25:31] = 2 # bright neighbor data[18:25, 25:31] += 200.0 positions = [(21.0, 21.0)] aper = CircularAperture(positions, r=8) labels = np.array([1], dtype=np.intp) batch = aper._photometry( data, error=error, mask=mask, segmentation_image=SegmentationImage(segm), labels=labels, mask_method='correct') mask_sum, mask_err, _area, *_ = aper._mask_photometry( data, error=error, mask=mask, method='exact', subpixels=5, segmentation=segm, labels=labels, mask_method='correct') assert_allclose(batch.flux, mask_sum, rtol=1e-12) assert_allclose(batch.flux_err, mask_err, rtol=1e-12) astropy-photutils-24be529/photutils/aperture/tests/test_stats.py000066400000000000000000003346401526452531300254150ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the stats module. """ import sys import tracemalloc from functools import cached_property from unittest.mock import patch import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.modeling.models import Gaussian2D from astropy.nddata import NDData, StdDevUncertainty from astropy.stats import SigmaClip, biweight_location, biweight_scale, mad_std from astropy.utils.exceptions import AstropyUserWarning from astropy.wcs import WCS from numpy.testing import assert_allclose, assert_equal from photutils.aperture import stats as stats_module from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.circle import CircularAnnulus, CircularAperture from photutils.aperture.core import _enable_batch_photometry from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.flags import APERTURE_FLAGS from photutils.aperture.photometry import AperturePhotometry from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture) from photutils.aperture.stats import (_BLOCK_MAX_PIXELS, _MAD_STD_SCALE, ApertureStats) from photutils.aperture.tests.conftest import NoBatchCircularAperture from photutils.datasets import make_100gaussians_image, make_wcs from photutils.segmentation import (SEGMENTATION_FLAGS, SegmentationImage, SourceCatalog) from photutils.utils._optional_deps import HAS_REGIONS from photutils.utils.exceptions import PhotutilsDeprecationWarning @_enable_batch_photometry class _NoneSpecCircular(CircularAperture): """ A `CircularAperture` subclass that opts in to the fast batch driver but whose ``_batch_shape_params`` hook returns `None`, so the batch driver is not used and the mask-based code path is exercised. """ def _batch_shape_params(self): return None @pytest.fixture(scope='class') def stats_data(request): """ Build the shared data and ApertureStats objects on the test class. """ cls = request.cls cls.data = make_100gaussians_image() cls.error = np.sqrt(np.abs(cls.data)) cls.wcs = make_wcs(cls.data.shape) cls.positions = ((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)) cls.aperture = CircularAperture(cls.positions, r=5) cls.sigclip = SigmaClip(sigma=3.0, maxiters=10) cls.apstats1 = ApertureStats(cls.data, cls.aperture, error=cls.error, wcs=cls.wcs, sigma_clip=None) cls.apstats2 = ApertureStats(cls.data, cls.aperture, error=cls.error, wcs=cls.wcs, sigma_clip=cls.sigclip) cls.unit = u.Jy cls.apstats1_units = ApertureStats(cls.data * cls.unit, cls.aperture, error=cls.error * cls.unit, wcs=cls.wcs, sigma_clip=None) cls.apstats2_units = ApertureStats(cls.data * cls.unit, cls.aperture, error=cls.error * cls.unit, wcs=cls.wcs, sigma_clip=cls.sigclip) class _ExpandedBboxCircular(CircularAperture): """ A `CircularAperture` subclass whose ``bbox`` is expanded by one pixel on each side, overriding the default implementation. Used to test that the ``ApertureStats`` bounding-box bounds honor a ``bbox`` override instead of using the vectorized fast path. """ @cached_property def bbox(self): boxes = [BoundingBox(ixmin=box.ixmin - 1, ixmax=box.ixmax + 1, iymin=box.iymin - 1, iymax=box.iymax + 1) for box in self._bbox] if self.isscalar: return boxes[0] return boxes @pytest.mark.usefixtures('stats_data') class BaseApertureStatsData: """ Shared data, aperture, and ApertureStats objects used by the ApertureStats test classes below. The ``stats_data`` fixture builds the objects once per test class rather than at import time, so collection stays cheap and each test class gets its own instances (no cached-property state is shared across classes). """ class TestProperties(BaseApertureStatsData): """ Tests for the computed source properties. """ @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_positions') # 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_err', '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_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} 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) 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 def test_cached_properties_class_cache(self): """ Test that _cached_properties is cached on the class and shared across instances. """ apstats2 = ApertureStats(self.data, self.aperture) result1 = self.apstats1._cached_properties result2 = apstats2._cached_properties assert result1 is result2 def test_repr_str(self): assert repr(self.apstats1) == str(self.apstats1) assert 'Length: 3' in repr(self.apstats1) class TestSumMethod(BaseApertureStatsData): """ Tests for the sum_method aperture-mask footprint. """ @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_positions') # Evaluate (cache) properties before slice for prop in apstats1.properties: if prop in scalar_props: continue if 'sum' in prop: # The sum-footprint properties must differ between the # two sum methods equal = True try: assert_equal(getattr(apstats1, prop), getattr(apstats2, prop)) except AssertionError: equal = False assert not equal, f'{prop} is unexpectedly equal' 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) phot = AperturePhotometry(self.data, self.aperture, error=self.error, method=method, subpixels=subpixels) assert_allclose(apstats.sum, phot.flux) assert_allclose(apstats.sum_err, phot.flux_err) def test_sum_aper_area_center_masked(self): """ Regression test that ``sum_aper_area`` is consistent with ``sum`` (and identical between the fast and mask-based code paths) when all center-method pixels are masked but the ``sum_method`` aperture still contains unmasked fractional boundary pixels. Previously, the mask-based path returned NaN for the area (via the center-method all-masked flag) while returning a finite ``sum`` from the same surviving fractional pixels. """ data = np.ones((11, 11)) aperture = CircularAperture((5.0, 5.0), r=1.4) # Mask exactly the center-method pixels. The fractional # (exact-method) boundary pixels remain unmasked. mask = aperture.to_mask(method='center').to_image(data.shape) > 0 fast = ApertureStats(data, aperture, mask=mask) with patch.object(ApertureStats, '_batch_inputs', property(lambda _: None)): slow = ApertureStats(data, aperture, mask=mask) slow_sum = slow.sum slow_area = slow.sum_aper_area assert fast._fast_sum is not None assert fast.sum > 0 # data is all ones, so the sum equals the surviving area assert_allclose(fast.sum_aper_area.value, fast.sum) assert_allclose(slow_sum, fast.sum) assert_allclose(slow_area, fast.sum_aper_area) # The center-method statistics remain all-masked (NaN) assert np.isnan(fast.center_aper_area.value) assert np.isnan(fast.mean) class TestMasking(BaseApertureStatsData): """ Tests for the mask keyword, local background, and apertures that do not overlap the data. """ 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_positions', 'sky_centroid', 'sky_centroid_icrs', 'sky_orientation', 'flags') 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))) # id=2 has masked pixels and id=3 is completely masked assert apstats[1].flags == APERTURE_FLAGS.MASKED_PIXELS assert apstats[2].flags == (APERTURE_FLAGS.MASKED_PIXELS | APERTURE_FLAGS.ALL_MASKED | APERTURE_FLAGS.UNDEFINED_SHAPE) # 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 nonfinite' 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_local_bkg_units(self): """ Test that local_bkg must carry the same units as a Quantity ``data`` input and that the results match the unitless case. """ unit = u.Jy data = np.ones(self.data.shape) * 100.0 local_bkg = (10, 20, 30) apstats1 = ApertureStats(data << unit, self.aperture, local_bkg=local_bkg * unit) apstats2 = ApertureStats(data, self.aperture, local_bkg=local_bkg) assert apstats1.sum.unit == unit assert_allclose(apstats1.sum.value, apstats2.sum) match = 'must all have the same units' with pytest.raises(ValueError, match=match): ApertureStats(data << unit, self.aperture, local_bkg=local_bkg) with pytest.raises(ValueError, match=match): ApertureStats(data << unit, self.aperture, local_bkg=local_bkg * u.mJy) with pytest.raises(ValueError, match=match): ApertureStats(data, self.aperture, local_bkg=local_bkg * unit) def test_no_aperture_overlap(self): aperture = CircularAperture(((0, 0), (100, 100), (-100, -100)), r=5) apstats = ApertureStats(self.data, aperture) exclude = ('isscalar', 'n_positions', 'sky_centroid', 'sky_centroid_icrs', 'sky_orientation', 'flags') 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))) assert apstats[0].flags == APERTURE_FLAGS.PARTIAL_OVERLAP assert apstats[2].flags == (APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS) class TestTable(BaseApertureStatsData): """ Tests for the to_table output and the properties list. """ 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 @pytest.mark.parametrize('columns', ['invalid', 'sum_method', 'isscalar', 'n_positions', ['id', 'subpixels']]) def test_invalid_column(self, columns): match = 'Invalid column name' with pytest.raises(ValueError, match=match): self.apstats1.to_table(columns=columns) def test_properties_excludes_scalar_attrs(self): """ Regression test to ensure that ``isscalar`` and ``n_positions`` (scalar values for the whole object, not per-source values) are not included in ``properties`` and so are not valid ``to_table`` columns. """ assert 'isscalar' not in self.apstats1.properties assert 'n_positions' not in self.apstats1.properties def test_deprecated_column(self): """ Regression test to ensure that deprecated column names are still accepted by ``to_table``, not rejected by the new column-name validation. """ match = "'xcentroid' attribute was deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match): tbl = self.apstats1.to_table(columns='xcentroid') assert tbl.colnames == ['x_centroid'] class TestIndexing(BaseApertureStatsData): """ Tests for indexing, slicing, and scalar ApertureStats objects. """ def test_slicing(self): apstats = self.apstats1 _ = apstats.to_table() apstat0 = apstats[1] assert apstat0.n_positions == 1 assert apstat0.id == np.array([2]) apstat1 = apstats.select_id(2) assert apstat1.n_positions == 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.id, [3, 2, 1]) assert_equal(apstat1.id, [3, 2, 1]) # Test select_ids when ids are not sorted apstat0 = apstats[[2, 1, 0]] apstat1 = apstat0.select_ids(2) assert apstat1.id == 2 mask = apstats.id >= 2 apstat0 = apstats[mask] assert len(apstat0) == 2 assert_equal(apstat0.id, [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_fancy_slicing_list_cache(self): """ Test fancy-index slicing of an evaluated list-valued cached property (e.g., the ``bbox`` BoundingBox list), which cannot be indexed directly with an array index. """ apstats = self.apstats1.copy() bbox = apstats.bbox # cache the per-source BoundingBox list sliced = apstats[[2, 1, 0]] assert sliced.bbox == [bbox[2], bbox[1], bbox[0]] def test_scalar_aperture_stats(self): apstats = self.apstats1[0] assert apstats.n_positions == 1 assert apstats.id == np.array([1]) tbl = apstats.to_table() assert len(tbl) == 1 def test_select_ids_scalar(self): """ Test that select_id(s) on a scalar object raises the same clear TypeError as indexing it. """ apstats = self.apstats1[0] match = "A scalar 'ApertureStats' object cannot be indexed" with pytest.raises(TypeError, match=match): apstats.select_ids(1) with pytest.raises(TypeError, match=match): apstats.select_id(1) @pytest.mark.parametrize('cached', ['moments', 'moments_central', 'cutout_centroid', 'centroid', 'covariance_eigvals', 'data_cutout', 'bbox', 'x_centroid']) def test_scalar_slice_after_cache(self, cached): """ Test that per-source properties are computed correctly for a scalar instance obtained by slicing a multisource instance when only *some* properties were cached on the parent before slicing. The ``_array`` accessor is used internally by several properties to index along the source axis. A per-source array can be stored either with its leading length-1 axis intact (freshly computed for a scalar instance) or with that axis removed (a multisource value cached on the parent and then sliced). If ``_array`` does not normalize both cases, moment-derived properties such as ``cutout_centroid`` raise an ``IndexError`` (or return wrong values) when a dependency was cached on the parent but the dependent property was not. This parametrizes over the dependencies that feed other properties through ``_array``, caches exactly one of them on the parent, slices to a scalar, and checks that every property matches the same scalar computed from scratch. """ scalar_props = ('isscalar', 'n_positions') # Reference scalar instance that recomputes every property from # scratch (nothing cached on the parent before slicing). ref = ApertureStats(self.data, self.aperture, error=self.error, wcs=self.wcs, sigma_clip=None)[0] # Cache exactly one dependency on the parent, then slice. parent = ApertureStats(self.data, self.aperture, error=self.error, wcs=self.wcs, sigma_clip=None) _ = getattr(parent, cached) scalar = parent[0] for prop in ref.properties: if prop in scalar_props: continue assert_equal(getattr(scalar, prop), getattr(ref, prop)) class TestDeprecations(BaseApertureStatsData): """ Tests for the deprecated attributes and columns. """ def test_deprecated_attributes(self): """ Test that deprecated attributes are still available and give the same value as the new attributes, but raise an PhotutilsDeprecationWarning. """ 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(PhotutilsDeprecationWarning, match=match): old_val = getattr(apstats, old_name) new_val = getattr(apstats, new_name) assert_equal(old_val, new_val) def test_deprecated_ids(self): """ Test that the ``ids`` attribute is deprecated and returns the same value as the ``id`` attribute. """ apstats = ApertureStats(self.data, self.aperture) match = 'deprecated in version 3.1' with pytest.warns(PhotutilsDeprecationWarning, match=match): old_val = apstats.ids assert_equal(old_val, apstats.id) # A scalar instance returns the scalar id scalar = apstats[0] with pytest.warns(PhotutilsDeprecationWarning, match=match): assert scalar.ids == scalar.id def test_deprecated_inertia_tensor(self): """ Test that the ``inertia_tensor`` attribute is deprecated and keeps its ``(y, x)`` order and its table column. """ apstats = ApertureStats(self.data, self.aperture) match = 'deprecated in version 3.1' with pytest.warns(PhotutilsDeprecationWarning, match=match): tensor = apstats.inertia_tensor assert 'inertia_tensor' not in apstats.properties mu = apstats.moments_central assert tensor.unit == u.pix**2 assert_allclose(tensor[:, 0, 0].value, mu[:, 0, 2]) assert_allclose(tensor[:, 1, 1].value, mu[:, 2, 0]) assert_allclose(tensor[:, 0, 1].value, -mu[:, 1, 1]) assert_allclose(tensor[:, 1, 0].value, -mu[:, 1, 1]) with pytest.warns(PhotutilsDeprecationWarning, match=match): tbl = apstats.to_table(columns=['inertia_tensor']) assert_allclose(tbl['inertia_tensor'], tensor) # A scalar instance returns a single (2, 2) tensor with pytest.warns(PhotutilsDeprecationWarning, match=match): assert apstats[0].inertia_tensor.shape == (2, 2) def test_deprecated_n_apertures(self): """ Test that the ``n_apertures`` attribute is deprecated and returns the same value as the ``n_positions`` attribute. """ apstats = ApertureStats(self.data, self.aperture) match = 'deprecated in version 3.1' with pytest.warns(PhotutilsDeprecationWarning, match=match): old_val = apstats.n_apertures assert old_val == apstats.n_positions class TestInputValidation(BaseApertureStatsData): """ Tests for the input validation and NDData input. """ @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_sky_region_without_wcs(self): from regions import CircleSkyRegion region = CircleSkyRegion(SkyCoord(10.0, 30.0, unit='deg'), radius=1.0 * u.arcsec) match = 'A wcs is required when using a SkyAperture' with pytest.raises(ValueError, match=match): ApertureStats(self.data, region) @pytest.mark.parametrize('dtype', [int, np.uint8, float]) def test_non_boolean_mask(self, dtype): """ Test that a mask that is not a boolean array is rejected. """ mask = np.zeros(self.data.shape, dtype=dtype) match = 'mask must be a boolean array' with pytest.raises(TypeError, match=match): ApertureStats(self.data, self.aperture, mask=mask) 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))) @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 is ignored. Its value is obtained from the input' 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) class TestMaskPathParity(BaseApertureStatsData): """ The mask-based code path must give the same results as the fast batch code path. """ @pytest.mark.parametrize('aperture', [ CircularAperture(((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)), r=6), CircularAnnulus(((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)), r_in=4, r_out=8), EllipticalAperture(((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)), 8, 4, theta=0.5), EllipticalAnnulus(((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)), 4, 8, 5, theta=0.5), RectangularAperture(((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)), 10, 6, theta=0.3), RectangularAnnulus(((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)), 8, 12, 9, theta=0.3), ]) @pytest.mark.parametrize('sigma_clip', [ None, SigmaClip(sigma=3.0, maxiters=5), SigmaClip(sigma=2.5, cenfunc='mean', stdfunc='mad_std', maxiters=None), ]) def test_fast_matches_mask(self, aperture, sigma_clip): """ Regression test that the fast Cython statistics path produces the same results as the mask-based path for every property, including when sigma-clipping is applied. """ numeric_props = ( 'sum', 'sum_err', 'sum_aper_area', 'center_aper_area', 'min', 'max', 'mean', 'median', 'mode', 'std', 'var', 'mad_std', 'biweight_location', 'biweight_midvariance', 'gini', 'x_centroid', 'y_centroid', 'fwhm', 'semimajor_axis', 'semiminor_axis', 'orientation', 'eccentricity', 'elongation', 'ellipticity', 'covariance_xx', 'covariance_yy', 'covariance_xy', 'moments', 'moments_central', 'mean_err', 'median_err') fast = ApertureStats(self.data, aperture, error=self.error, sigma_clip=sigma_clip) # Force the mask-based path by disabling the batch-driver # inputs, which disables both the fast center-value gather # and the fast sum path. Patching only ``_fast_gather`` is # insufficient because ``_fast_sum`` would still handle the # ``sum``, ``sum_err``, and ``sum_aper_area`` comparisons. with patch.object(ApertureStats, '_batch_inputs', property(lambda _: None)): slow = ApertureStats(self.data, aperture, error=self.error, sigma_clip=sigma_clip) assert slow._fast_gather is None assert slow._fast_sum is None slow_values = {prop: getattr(slow, prop) for prop in numeric_props} for prop in numeric_props: assert_allclose(getattr(fast, prop), slow_values[prop], rtol=1e-7, atol=1e-9, equal_nan=True, err_msg=f'mismatch in {prop}') @pytest.mark.parametrize('sigma_clip', [ SigmaClip(sigma=3.0, cenfunc=np.nanmedian), SigmaClip(sigma=3.0, stdfunc=np.nanstd), SigmaClip(sigma=3.0, grow=1.5), ]) def test_sigma_clip_fallback(self, sigma_clip): """ Test that a SigmaClip not supported by the fast clipping kernel (callable cenfunc/stdfunc or spatial growing) falls back to the mask-based code path. """ apstats = ApertureStats(self.data, self.aperture, error=self.error, sigma_clip=sigma_clip) assert apstats._fast_gather is None assert np.all(np.isfinite(apstats.mean)) assert np.all(np.isfinite(apstats.mean_err)) def test_sigma_clip_callable(self): """ Test that a SigmaClip with callable cenfunc/stdfunc (mask-based path) gives the same results as the equivalent string cenfunc/stdfunc (fast path). """ fast = ApertureStats(self.data, self.aperture, error=self.error, sigma_clip=SigmaClip(sigma=3.0)) slow = ApertureStats(self.data, self.aperture, error=self.error, sigma_clip=SigmaClip(sigma=3.0, cenfunc=np.nanmedian, stdfunc=np.nanstd)) assert fast._fast_gather is not None assert slow._fast_gather is None for prop in ('mean', 'median', 'std', 'mean_err', 'median_err'): assert_allclose(getattr(fast, prop), getattr(slow, prop), rtol=1e-7, equal_nan=True, err_msg=prop) def test_batch_hook_fallback(self): """ Test that apertures that do not opt into the batch driver (no own-class hook, or a hook returning None) use the mask-based path and give the same results as the fast path. """ nohook = ApertureStats(self.data, NoBatchCircularAperture(self.positions, r=5)) nospec = ApertureStats(self.data, _NoneSpecCircular(self.positions, r=5)) assert nohook._fast_gather is None assert nospec._fast_gather is None ref = ApertureStats(self.data, self.aperture) assert_allclose(nohook.mean, ref.mean, equal_nan=True) assert_allclose(nospec.mean, ref.mean, equal_nan=True) def test_data_mask_fallback(self): """ Test that data inputs not supported by the fast batch driver fall back to the mask-based code path. """ mdata = np.ma.MaskedArray( self.data, mask=np.zeros(self.data.shape, dtype=bool)) assert ApertureStats(mdata, self.aperture)._fast_gather is None @pytest.mark.parametrize('sum_method', ['exact', 'center']) @pytest.mark.parametrize('with_units', [False, True]) def test_slow_path_extended(self, sum_method, with_units): """ Test that the mask-based code path matches the fast path for data units, ``sum_method='center'``, and a nonoverlapping source, exercising the unit-application and no-overlap branches. """ positions = [*self.positions, (-50.0, -50.0)] # last has no overlap data = self.data error = self.error if with_units: data = data * self.unit error = error * self.unit fast = ApertureStats(data, CircularAperture(positions, r=5), error=error, sum_method=sum_method) slow = ApertureStats(data, NoBatchCircularAperture(positions, r=5), error=error, sum_method=sum_method) assert fast._fast_gather is not None assert slow._fast_gather is None props = ('sum', 'sum_err', 'center_aper_area', 'sum_aper_area', 'min', 'max', 'mean', 'median', 'mode', 'std', 'var', 'mad_std', 'mean_err', 'median_err', 'moments', 'moments_central') for prop in props: assert_allclose(np.asarray(getattr(fast, prop)), np.asarray(getattr(slow, prop)), rtol=1e-7, atol=1e-9, equal_nan=True, err_msg=prop) def test_slow_path_scalar(self): """ Test the mask-based code path for a scalar (single-aperture) catalog, exercising the scalar branches of the mask-based helpers. """ slow = ApertureStats(self.data, NoBatchCircularAperture(self.positions[0], r=5), error=self.error) fast = ApertureStats(self.data, CircularAperture(self.positions[0], r=5), error=self.error) assert slow.isscalar assert slow._fast_gather is None for prop in ('mean', 'median', 'std', 'mean_err', 'median_err', 'moments', 'sum'): assert_allclose(np.asarray(getattr(fast, prop)), np.asarray(getattr(slow, prop)), rtol=1e-7, equal_nan=True, err_msg=prop) def test_slow_path_segmentation(self): """ Test that the mask-based code path applies segmentation-based masking identically to the fast path. """ data = np.ones((50, 50)) segm = np.zeros((50, 50), dtype=int) data[18:25, 18:25] = 10.0 segm[18:25, 18:25] = 1 data[20:25, 26:32] = 100.0 segm[20:25, 26:32] = 2 positions = [(21, 21), (28, 22)] kwargs = {'segmentation_image': SegmentationImage(segm), 'labels': [1, 2], 'mask_method': 'mask'} fast = ApertureStats(data, CircularAperture(positions, r=6), **kwargs) slow = ApertureStats(data, NoBatchCircularAperture(positions, r=6), **kwargs) assert slow._fast_gather is None for prop in ('sum', 'mean', 'median', 'std', 'mean_err', 'median_err'): assert_allclose(getattr(fast, prop), getattr(slow, prop), rtol=1e-7, equal_nan=True, err_msg=prop) def test_slow_path_mask(self): """ Test that the mask-based code path applies the input ``mask`` identically to the fast path. """ rng = np.random.default_rng(123) mask = rng.random(self.data.shape) > 0.7 fast = ApertureStats(self.data, CircularAperture(self.positions, r=5), error=self.error, mask=mask) slow_aper = NoBatchCircularAperture(self.positions, r=5) slow = ApertureStats(self.data, slow_aper, error=self.error, mask=mask) assert slow._fast_gather is None for prop in ('sum', 'mean', 'median', 'std', 'mean_err', 'median_err'): assert_allclose(getattr(fast, prop), getattr(slow, prop), rtol=1e-7, equal_nan=True, err_msg=prop) def test_mask_nonfinite_data(self): """ Test that the fast batch driver correctly combines a user-supplied ``mask`` with the mask derived from nonfinite ``data`` values, giving the same result as the mask-based code path. """ data = self.data.copy() data[168, 145] = np.nan rng = np.random.default_rng(123) mask = rng.random(data.shape) > 0.7 fast = ApertureStats(data, CircularAperture(self.positions, r=5), error=self.error, mask=mask) assert fast._batch_inputs is not None slow_aper = NoBatchCircularAperture(self.positions, r=5) slow = ApertureStats(data, slow_aper, error=self.error, mask=mask) assert slow._fast_gather is None for prop in ('sum', 'mean', 'median', 'std', 'mean_err', 'median_err'): assert_allclose(getattr(fast, prop), getattr(slow, prop), rtol=1e-7, equal_nan=True, err_msg=prop) class TestEdgeClippedCentroid: """ Tests that the centroid is correct for an aperture whose bounding box extends beyond the data. """ @pytest.mark.parametrize(('source', 'position'), [ ((1, 12), (1.0, 12.0)), ((3, 1), (3.0, 1.0)), ((1, 1), (0.5, 0.5)), ((23, 12), (23.0, 12.0)), ((12, 23), (12.0, 23.5)), ]) @pytest.mark.parametrize('fast', [True, False]) def test_centroid(self, source, position, fast, monkeypatch): if not fast: monkeypatch.setattr(ApertureStats, '_batch_inputs', None) data = np.zeros((25, 25)) x_source, y_source = source data[y_source, x_source] = 10.0 aperture = CircularAperture([position, (12.0, 12.0)], r=3.0) stats = ApertureStats(data, aperture) assert_allclose(stats.centroid[0], source) assert_allclose(stats.x_centroid[0], x_source) assert_allclose(stats.y_centroid[0], y_source) flags = stats.decode_flags()[1] assert 'partial_overlap' in flags assert 'centroid_outside_bbox' not in flags def test_sky_centroid(self): data = np.zeros((25, 25)) data[12, 1] = 10.0 wcs = WCS(naxis=2) wcs.wcs.crpix = [12.5, 12.5] wcs.wcs.cdelt = [-0.001, 0.001] wcs.wcs.crval = [10.0, 30.0] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] stats = ApertureStats(data, CircularAperture((1.0, 12.0), r=3.0), wcs=wcs) expected = wcs.pixel_to_world(1.0, 12.0) assert stats.sky_centroid.separation(expected) < 1e-6 * u.arcsec class TestStandardErrors(BaseApertureStatsData): """ Tests for the mean_err and median_err standard errors. """ def test_mean_err_median_err(self): """ Test the standard-error properties against their definitions. """ apstats = ApertureStats(self.data, self.aperture, error=self.error) n_pixels = apstats.center_aper_area.value sample_std = apstats.std * np.sqrt(n_pixels / (n_pixels - 1.0)) expected_mean_err = sample_std / np.sqrt(n_pixels) assert_allclose(apstats.mean_err, expected_mean_err) assert_allclose(apstats.median_err, np.sqrt(np.pi / 2.0) * expected_mean_err) def test_mean_err_single_pixel(self): """ Test that the standard error is NaN for apertures with fewer than two unmasked pixels. """ data = np.zeros((11, 11)) data[5, 5] = 1.0 apstats = ApertureStats(data, CircularAperture((5, 5), r=0.5)) assert apstats.center_aper_area.value < 2 assert np.isnan(apstats.mean_err) assert np.isnan(apstats.median_err) def test_mean_err_units(self): """ Test that the standard-error properties carry the data unit. """ apstats = ApertureStats(self.data * self.unit, self.aperture, error=self.error * self.unit) assert apstats.mean_err.unit == self.unit assert apstats.median_err.unit == self.unit class TestDdof(BaseApertureStatsData): """ Tests for the ddof keyword used by the var and std properties. """ @pytest.mark.parametrize('with_units', [False, True]) def test_ddof(self, with_units): """ Test that the ``ddof`` keyword rescales ``var`` and ``std`` by ``N / (N - ddof)`` and that the standard errors are unaffected. """ data = self.data error = self.error if with_units: data = data * self.unit error = error * self.unit apstats0 = ApertureStats(data, self.aperture, error=error) apstats1 = ApertureStats(data, self.aperture, error=error, ddof=1) n_pixels = apstats0.center_aper_area.value var0 = apstats0.var std0 = apstats0.std var1 = apstats1.var std1 = apstats1.std if with_units: assert var1.unit == self.unit**2 assert std1.unit == self.unit var0, std0 = var0.value, std0.value var1, std1 = var1.value, std1.value assert_allclose(var1, var0 * n_pixels / (n_pixels - 1.0)) assert_allclose(std1, std0 * np.sqrt(n_pixels / (n_pixels - 1.0))) # The standard errors are defined with the sample std and are # independent of the ``ddof`` keyword. assert_allclose(apstats0.mean_err, apstats1.mean_err, equal_nan=True) assert_allclose(apstats0.median_err, apstats1.median_err, equal_nan=True) def test_ddof_slow_path(self): """ Test the ``ddof`` rescaling on the mask-based (slow) code path. """ fast = ApertureStats(self.data, CircularAperture(self.positions, r=5), ddof=1) slow_aper = NoBatchCircularAperture(self.positions, r=5) slow = ApertureStats(self.data, slow_aper, ddof=1) assert slow._fast_gather is None assert_allclose(fast.var, slow.var, equal_nan=True) assert_allclose(fast.std, slow.std, equal_nan=True) def test_ddof_undefined(self): """ Test that ``var`` and ``std`` are NaN when ``N <= ddof``. """ data = np.zeros((11, 11)) data[5, 5] = 1.0 aper = CircularAperture((5, 5), r=0.5) apstats = ApertureStats(data, aper, ddof=1) assert apstats.center_aper_area.value < 2 assert np.isnan(apstats.var) assert np.isnan(apstats.std) # The default (ddof=0) gives a finite (zero) variance. assert ApertureStats(data, aper).var == 0.0 def test_ddof_invalid(self): """ Test that an invalid ``ddof`` raises a ``ValueError``. """ match = 'ddof must be a nonnegative integer' for bad in (-1, 1.5, True, False): with pytest.raises(ValueError, match=match): ApertureStats(self.data, self.aperture, ddof=bad) class TestMadStdScale: """ The mad_std scale factor must match astropy.stats.mad_std. """ def test_scale(self): """ Test that the hardcoded MAD-to-sigma scale factor matches `astropy.stats.mad_std`. The factor is duplicated in ``photutils.aperture.stats`` and ``photutils.aperture._batch_stats`` (a Cython constant cannot be shared directly). The fast-vs-mask regression tests cover the Cython copy. """ rng = np.random.default_rng(0) values = rng.normal(size=101) mad = np.median(np.abs(values - np.median(values))) assert_allclose(_MAD_STD_SCALE * mad, mad_std(values), rtol=1e-14) class TestRegionInput: """ A regions.Region input must give the same results as the equivalent aperture. """ @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_region_matches_aperture(self): 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)) class TestApertureMetadata: """ Tests for the aperture metadata stored in the output table. """ def test_metadata(self): 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 class TestSigmaClipSharedInstance: """ Tests for calling a shared SigmaClip instance from the astropy fallback clipping path. Astropy SigmaClip stores internal state on the instance during calls, and the fallback path is reachable from two different cached properties (the center- and sum-footprint cutouts) that do not share a lock. ApertureStats must therefore never call the user's SigmaClip instance directly. """ def test_user_sigma_clip_instance_not_called(self, monkeypatch): """ Test that the user's SigmaClip instance is never called directly (an internal copy must be used). """ data = np.ones((51, 51)) data[20:30, 20:30] = 100.0 aper = CircularAperture([(25, 25), (10, 10)], r=8) # A callable cenfunc is unsupported by the batch clipping # kernels and forces the astropy SigmaClip fallback path. sigclip = SigmaClip(sigma=3.0, maxiters=5, cenfunc=np.nanmedian) called_ids = [] orig_call = SigmaClip.__call__ def spy(self, *args, **kwargs): called_ids.append(id(self)) return orig_call(self, *args, **kwargs) monkeypatch.setattr(SigmaClip, '__call__', spy) apstats = ApertureStats(data, aper, sigma_clip=sigclip, sum_method='exact') _ = apstats.mean _ = apstats.sum assert len(called_ids) > 0 # the fallback path was exercised assert id(sigclip) not in called_ids def test_fallback_thread_safety(self): """ Test that computing a center-footprint and a sum-footprint property concurrently on the same instance gives the same results as the serial computation. """ from concurrent.futures import ThreadPoolExecutor rng = np.random.default_rng(0) n_src = 200 ny = 51 yc = ny // 2 xs = np.arange(25, 25 + 50 * n_src, 50) data = rng.normal(0.0, 1.0, (ny, 50 * n_src + 50)) for i, x in enumerate(xs): # Strong per-source scale differences make any clipping # bound contamination between sources visible. scale = 10.0 ** (i % 6) data[:, x - 20:x + 20] = rng.normal(scale, scale / 10.0, (ny, 40)) data[yc - 2:yc + 2, x - 2:x + 2] = scale * 50.0 # outliers positions = np.column_stack([xs, np.full(n_src, yc)]) aper = CircularAperture(positions, r=15) sigclip = SigmaClip(sigma=2.0, maxiters=10, cenfunc=np.nanmedian) ref = ApertureStats(data, aper, sigma_clip=sigclip, sum_method='exact') ref_mean = ref.mean ref_sum = ref.sum for _ in range(3): apstats = ApertureStats(data, aper, sigma_clip=sigclip, sum_method='exact') with ThreadPoolExecutor(max_workers=2) as executor: mean_future = executor.submit(getattr, apstats, 'mean') sum_future = executor.submit(getattr, apstats, 'sum') mean = mean_future.result() total = sum_future.result() assert_allclose(mean, ref_mean) assert_allclose(total, ref_sum) class TestSigmaClipBiweightStrings: """ Tests for the SigmaClip 'biweight' cenfunc/stdfunc string options in the fast clipping kernels. """ @staticmethod def _make_sigma_clip_biweight(**kwargs): """ Create a SigmaClip instance using the 'biweight' cenfunc and stdfunc string options, emulating them on astropy versions that do not support these options. """ def nanbiloc(data, axis=None): return biweight_location(data, axis=axis, ignore_nan=True) def nanbiscale(data, axis=None): return biweight_scale(data, axis=axis, ignore_nan=True) try: return SigmaClip(cenfunc='biweight', stdfunc='biweight', **kwargs) except ValueError: sigma_clip = SigmaClip(**kwargs) sigma_clip.cenfunc = 'biweight' sigma_clip.stdfunc = 'biweight' sigma_clip._cenfunc_parsed = nanbiloc sigma_clip._stdfunc_parsed = nanbiscale return sigma_clip def test_biweight_matches_fallback(self, monkeypatch): """ Test that the 'biweight' string options are accepted by the fast clipping kernels and match the mask-based fallback path, which clips using the astropy SigmaClip instance. """ rng = np.random.default_rng(0) data = rng.normal(10.0, 2.0, (101, 101)) data[::7, ::7] = 200.0 # outliers positions = [(20.0, 20.0), (50.0, 50.0), (80.5, 80.5)] aper = CircularAperture(positions, r=12) sigclip = self._make_sigma_clip_biweight(sigma=2.0, maxiters=5) apstats1 = ApertureStats(data, aper, sigma_clip=sigclip, sum_method='exact') assert apstats1._fast_clip_spec() is not None mean1 = apstats1.mean std1 = apstats1.std sum1 = apstats1.sum # Force the mask-based fallback path. monkeypatch.setattr(ApertureStats, '_fast_clip_spec', lambda _self: None) apstats2 = ApertureStats(data, aper, sigma_clip=sigclip, sum_method='exact') assert apstats2._fast_clip_spec() is None assert_allclose(mean1, apstats2.mean, rtol=1e-10) assert_allclose(std1, apstats2.std, rtol=1e-10) assert_allclose(sum1, apstats2.sum, rtol=1e-10) def test_biweight_final_bounds_survivors(self): """ Test that the biweight clipping keeps every value within the final bounds, including a value dropped in an earlier iteration. The value 4 is dropped in the second iteration (upper bound 3.87) and is within the final bounds (upper bound 4.14). """ values = np.array([-2, 1, -2, 1, -5, 1, 1, -6, -3, -3, 2, -4, -2, -3, -2, -1, 6, 2, 4, 2, 0, 1, 1, 3], dtype=float) kept = values n_dropped = 1 while n_dropped: center = biweight_location(kept) scale = biweight_scale(kept) lower = center - 1.5 * scale upper = center + 1.5 * scale survivors = kept[(kept >= lower) & (kept <= upper)] n_dropped = kept.size - survivors.size kept = survivors expected = values[(values >= lower) & (values <= upper)] assert 4.0 not in kept assert 4.0 in expected data = values.reshape(4, 6) aper = RectangularAperture((2.5, 1.5), w=6, h=4) sigclip = self._make_sigma_clip_biweight(sigma=1.5, maxiters=20) apstats = ApertureStats(data, aper, sigma_clip=sigclip, sum_method='center') assert apstats._fast_clip_spec() is not None assert_allclose(apstats.mean, np.mean(expected), rtol=1e-12) assert_allclose(apstats.sum, np.sum(expected), rtol=1e-12) def test_sigma_clip_sorted_values_reuse(monkeypatch): """ Test that the order statistics computed from the sigma-clip kernel's sorted output (reused by _sorted_values instead of sorting the clipped values again) match the mask-based fallback path. """ rng = np.random.default_rng(0) data = rng.normal(10.0, 2.0, (101, 101)) data[::5, ::5] = 200.0 # outliers positions = [(20.0, 20.0), (50.0, 50.0), (80.5, 80.5), (3.0, 3.0)] aper = CircularAperture(positions, r=12) sigclip = SigmaClip(sigma=2.0, maxiters=10) apstats1 = ApertureStats(data, aper, sigma_clip=sigclip) props = ('min', 'max', 'median', 'mad_std', 'biweight_location', 'std', 'mean') results1 = [getattr(apstats1, prop) for prop in props] # Force the mask-based fallback path. monkeypatch.setattr(ApertureStats, '_fast_clip_spec', lambda _self: None) apstats2 = ApertureStats(data, aper, sigma_clip=sigclip) for prop, result1 in zip(props, results1, strict=True): assert_allclose(result1, getattr(apstats2, prop), rtol=1e-10) class TestNThreads: """ Tests for the n_threads keyword. """ @staticmethod def make_inputs(): """ Build a deterministic image (with nonfinite values), error, mask, and positions (including off-edge positions). """ rng = np.random.default_rng(0) data = rng.normal(100.0, 5.0, (120, 130)) data[3, 3] = np.nan data[50, 50] = np.inf error = np.abs(rng.normal(5.0, 0.5, data.shape)) + 0.1 mask = np.zeros(data.shape, dtype=bool) mask[60:65, 85:90] = True positions = np.column_stack( [rng.uniform(-5, data.shape[1] + 5, 57), rng.uniform(-5, data.shape[0] + 5, 57)]) return data, error, mask, positions @staticmethod def assert_stats_equal(stats1, stats2, *, rtol=0): """ Assert that all properties of two ApertureStats instances are equal. Floating-point values must be exactly equal when ``rtol`` is 0 (the default) and must agree to within ``rtol`` otherwise. The tolerance is relative to each value and also to the largest magnitude of the same source, because elements that are analytically zero (e.g., the first-order central moments) are rounding residuals of much larger terms of that source. """ def check(value1, value2): value1 = np.asarray(value1) value2 = np.asarray(value2) if rtol and value1.dtype.kind == 'f': finite = np.where(np.isfinite(value2), np.abs(value2), 0.0) axes = tuple(range(1, value2.ndim)) scale = (finite.max(axis=axes, keepdims=True) if axes else finite) scale = np.where(scale > 0, scale, 1.0) assert_allclose(value1 / scale, value2 / scale, rtol=rtol, atol=rtol, equal_nan=True) else: assert_equal(value1, value2) for prop in stats1.properties: value1 = getattr(stats1, prop) value2 = getattr(stats2, prop) if prop.endswith('cutout'): for arr1, arr2 in zip(value1, value2, strict=True): arr1 = np.ma.filled(arr1, np.nan) arr2 = np.ma.filled(arr2, np.nan) check(arr1, arr2) elif prop.startswith('sky_'): # SkyCoord without a wcs is None or an array of None. if hasattr(value1, 'ra'): check(value1.ra.deg, value2.ra.deg) check(value1.dec.deg, value2.dec.deg) else: assert_equal(value1, value2) else: check(value1, value2) @pytest.mark.parametrize('n_threads', [2, 8]) @pytest.mark.parametrize('use_sigma_clip', [False, True]) def test_identical_results(self, n_threads, use_sigma_clip): """ Test that multithreaded statistics are identical to the single-threaded computation for every property, including for off-edge positions, masked pixels, and nonfinite data values. """ data, error, mask, positions = self.make_inputs() wcs = make_wcs(data.shape) sigma_clip = (SigmaClip(sigma=3.0, maxiters=10) if use_sigma_clip else None) aper = CircularAperture(positions, r=7.0) stats1 = ApertureStats(data, aper, error=error, mask=mask, wcs=wcs, sigma_clip=sigma_clip) stats2 = ApertureStats(data, aper, error=error, mask=mask, wcs=wcs, sigma_clip=sigma_clip, n_threads=n_threads) assert stats2.n_threads == n_threads self.assert_stats_equal(stats1, stats2) def test_local_bkg_ddof_sum_method(self): """ Test that the per-source local background values are chunked together with the positions. """ data, error, _, positions = self.make_inputs() local_bkg = np.linspace(-1.0, 1.0, positions.shape[0]) aper = CircularAperture(positions, r=6.0) kwargs = {'error': error, 'local_bkg': local_bkg, 'ddof': 1, 'sum_method': 'center'} stats1 = ApertureStats(data, aper, **kwargs) stats2 = ApertureStats(data, aper, n_threads=4, **kwargs) for prop in ('sum', 'sum_err', 'std', 'median', 'mean'): assert_equal(getattr(stats1, prop), getattr(stats2, prop)) def test_segmentation_masking(self): """ Test that the per-source segmentation labels are chunked together with the positions. """ data = np.ones((50, 50)) segm = np.zeros((50, 50), dtype=int) data[18:25, 18:25] = 10.0 segm[18:25, 18:25] = 1 data[20:25, 26:32] = 100.0 segm[20:25, 26:32] = 2 positions = [(21.0, 21.0), (28.0, 22.0), (21.0, 21.5), (28.5, 22.0), (20.5, 21.0)] labels = [1, 2, 1, 2, 1] aper = CircularAperture(positions, r=8.0) kwargs = {'segmentation_image': SegmentationImage(segm), 'labels': labels, 'mask_method': 'mask'} stats1 = ApertureStats(data, aper, **kwargs) stats2 = ApertureStats(data, aper, n_threads=3, **kwargs) for prop in ('sum', 'mean', 'median', 'flags'): assert_equal(getattr(stats1, prop), getattr(stats2, prop)) def test_more_threads_than_positions(self): """ Test that n_threads larger than the number of positions gives identical results. """ data = np.ones((40, 40)) aper = CircularAperture([(20, 20), (10, 10), (30, 25)], r=5.0) stats1 = ApertureStats(data, aper) stats2 = ApertureStats(data, aper, n_threads=8) self.assert_stats_equal(stats1, stats2) def test_scalar_aperture(self): """ Test that a scalar aperture position with n_threads > 1 falls back to a single-chunk (serial) computation. """ data = np.ones((40, 40)) aper = CircularAperture((20, 20), r=5.0) stats1 = ApertureStats(data, aper) stats2 = ApertureStats(data, aper, n_threads=4) assert_equal(stats1.sum, stats2.sum) assert_equal(stats1.median, stats2.median) def test_mask_based_fallback(self): """ Test that apertures that do not support the batch code path give correct results with n_threads > 1 (the mask-based code path stays serial). """ data, error, _, positions = self.make_inputs() aper = NoBatchCircularAperture(positions, r=7.0) stats = ApertureStats(data, aper, error=error, n_threads=4) assert stats._batch_inputs is None ref = ApertureStats(data, CircularAperture(positions, r=7.0), error=error) assert_allclose(stats.sum, ref.sum, equal_nan=True) assert_allclose(stats.median, ref.median, equal_nan=True) def test_empty_positions(self): """ Test that an aperture with zero positions works with n_threads > 1 (regression test for the chunking helper, which must fall back to the serial path for zero chunks). """ data = np.ones((11, 11)) aper = CircularAperture(np.empty((0, 2)), r=3.0) stats = ApertureStats(data, aper, n_threads=4) assert stats.n_positions == 0 assert stats.sum.shape == (0,) assert stats.median.shape == (0,) def test_indexing_preserves_n_threads(self): """ Test that slicing an ApertureStats instance preserves the n_threads value. """ data = np.ones((40, 40)) aper = CircularAperture([(20, 20), (10, 10), (30, 25)], r=5.0) stats = ApertureStats(data, aper, n_threads=4) assert stats[1:].n_threads == 4 assert stats[0].n_threads == 4 def test_invalid_n_threads(self): """ Test that an error is raised if n_threads is not a positive integer. """ data = np.ones((40, 40)) aper = CircularAperture((20, 20), r=5.0) match = 'n_threads must be a positive integer' for n_threads in (0, -1, 2.5, True): with pytest.raises(ValueError, match=match): ApertureStats(data, aper, n_threads=n_threads) class TestBoundedMemory: """ Tests for the block-by-block processing used when the packed pixel buffers would exceed the ``_BLOCK_MAX_PIXELS`` budget. """ @staticmethod def make_stats_pair(monkeypatch, data, aper, *, max_pixels=500, **kwargs): """ Return ApertureStats instances made with the default budget and with a small budget that forces block processing. """ stats1 = ApertureStats(data, aper, **kwargs) monkeypatch.setattr('photutils.aperture.stats._BLOCK_MAX_PIXELS', max_pixels) stats2 = ApertureStats(data, aper, **kwargs) return stats1, stats2 @pytest.mark.parametrize('n_threads', [1, 3]) @pytest.mark.parametrize('use_sigma_clip', [False, True]) def test_identical_results(self, monkeypatch, n_threads, use_sigma_clip): """ Test that block processing gives results identical to the single-buffer computation for every property, including for off-edge positions, masked pixels, and nonfinite data values. """ data, error, mask, positions = TestNThreads.make_inputs() wcs = make_wcs(data.shape) sigma_clip = (SigmaClip(sigma=3.0, maxiters=10) if use_sigma_clip else None) aper = CircularAperture(positions, r=7.0) stats1, stats2 = self.make_stats_pair( monkeypatch, data, aper, error=error, mask=mask, wcs=wcs, sigma_clip=sigma_clip, n_threads=n_threads) TestNThreads.assert_stats_equal(stats1, stats2) @pytest.mark.parametrize('use_sigma_clip', [False, True]) def test_packed_buffers_not_cached(self, monkeypatch, use_sigma_clip): """ Test that the full packed buffers are never built or cached by the statistics when block processing is used. """ data, error, _, positions = TestNThreads.make_inputs() sigma_clip = SigmaClip(sigma=3.0) if use_sigma_clip else None aper = CircularAperture(positions, r=7.0) stats1, stats2 = self.make_stats_pair( monkeypatch, data, aper, error=error, sigma_clip=sigma_clip) props = ('min', 'max', 'mean', 'median', 'std', 'mad_std', 'biweight_location', 'biweight_midvariance', 'gini', 'moments', 'moments_central', 'centroid', 'sum', 'sum_err', 'center_aper_area', 'median_err', 'flags') for prop in props: assert_equal(getattr(stats1, prop), getattr(stats2, prop)) assert '_fast_gather' in stats1.__dict__ assert '_fast_gather' not in stats2.__dict__ assert '_sorted_values' not in stats2.__dict__ @pytest.mark.parametrize('use_sigma_clip', [False, True]) def test_to_table(self, monkeypatch, use_sigma_clip): """ Test that to_table, which calculates the reductions for all of its columns in a single pass over the blocks, gives identical results. """ data, error, _, positions = TestNThreads.make_inputs() sigma_clip = SigmaClip(sigma=3.0) if use_sigma_clip else None aper = CircularAperture(positions, r=7.0) stats1, stats2 = self.make_stats_pair( monkeypatch, data, aper, error=error, sigma_clip=sigma_clip) columns = [*stats1.default_columns, 'gini', 'moments_central'] columns.remove('sky_centroid') tbl1 = stats1.to_table(columns=columns) tbl2 = stats2.to_table(columns=columns) for column in columns: assert_equal(tbl1[column].value, tbl2[column].value) assert '_fast_gather' not in stats2.__dict__ # Only the requested reductions are calculated. stats3, stats4 = self.make_stats_pair(monkeypatch, data, aper) tbl4 = stats4.to_table(columns=['id', 'mean', 'sum']) assert_equal(tbl4['mean'].value, stats3.mean) assert set(stats4._block_cache) == {'mean_var', 'minmax', 'meta'} # Columns that do not use the center gather do not start a pass # over the blocks or build the packed buffers stats5, stats6 = self.make_stats_pair(monkeypatch, data, aper) for stats in (stats5, stats6): tbl = stats.to_table(columns=['id', 'sum', 'sum_err']) assert_equal(tbl['sum'].value, stats3.sum) assert not stats._block_cache assert '_fast_gather' not in stats.__dict__ def test_shape_flags(self, monkeypatch): """ Test that block processing gives identical results for sources with the ``undefined_shape``, ``singular_covariance``, and ``centroid_outside_bbox`` flags. """ data = np.zeros((40, 110)) mask = np.zeros(data.shape, dtype=bool) mask[14:27, 4:17] = True # fully masked data[16:25, 26:35] = -1.0 # negative flux data[20, 50] = 100.0 # single pixel data[20, 66] = 10.0 # centroid outside the bounding box data[20, 74] = -9.0 data[17:24, 87:94] = 5.0 positions = [(10, 20), (30, 20), (50, 20), (70, 20), (90, 20)] aper = CircularAperture(positions, r=6.0) stats1, stats2 = self.make_stats_pair(monkeypatch, data, aper, mask=mask) expected = [APERTURE_FLAGS.MASKED_PIXELS | APERTURE_FLAGS.ALL_MASKED | APERTURE_FLAGS.UNDEFINED_SHAPE, APERTURE_FLAGS.UNDEFINED_SHAPE, APERTURE_FLAGS.SINGULAR_COVARIANCE, APERTURE_FLAGS.UNDEFINED_SHAPE | APERTURE_FLAGS.CENTROID_OUTSIDE_BBOX, 0] assert_equal(stats2.flags, expected) assert '_fast_gather' not in stats2.__dict__ TestNThreads.assert_stats_equal(stats1, stats2) # to_table calculates the reductions in a single pass. stats3, stats4 = self.make_stats_pair(monkeypatch, data, aper, mask=mask) columns = list(stats3.default_columns) columns.remove('sky_centroid') tbl3 = stats3.to_table(columns=columns) tbl4 = stats4.to_table(columns=columns) for column in columns: assert_equal(np.asarray(tbl3[column]), np.asarray(tbl4[column])) def test_local_bkg_ddof_sum_method(self, monkeypatch): """ Test that the per-source local background values are divided into blocks together with the positions. """ data, error, _, positions = TestNThreads.make_inputs() local_bkg = np.linspace(-1.0, 1.0, positions.shape[0]) aper = CircularAperture(positions, r=6.0) stats1, stats2 = self.make_stats_pair( monkeypatch, data, aper, error=error, local_bkg=local_bkg, ddof=1, sum_method='center') for prop in ('sum', 'sum_err', 'std', 'median', 'mean'): assert_equal(getattr(stats1, prop), getattr(stats2, prop)) @pytest.mark.parametrize('mask_method', ['mask', 'correct']) def test_segmentation_masking(self, monkeypatch, mask_method): """ Test that the per-source segmentation labels are divided into blocks together with the positions. """ data = np.ones((50, 50)) segm = np.zeros((50, 50), dtype=int) data[18:25, 18:25] = 10.0 segm[18:25, 18:25] = 1 data[20:25, 26:32] = 100.0 segm[20:25, 26:32] = 2 positions = [(21.0, 21.0), (28.0, 22.0), (21.0, 21.5), (28.5, 22.0), (20.5, 21.0)] labels = [1, 2, 1, 2, 1] aper = CircularAperture(positions, r=8.0) stats1, stats2 = self.make_stats_pair( monkeypatch, data, aper, segmentation_image=SegmentationImage(segm), labels=labels, mask_method=mask_method) for prop in ('sum', 'mean', 'median', 'centroid', 'flags'): assert_equal(getattr(stats1, prop), getattr(stats2, prop)) def test_source_larger_than_budget(self, monkeypatch): """ Test that a source whose bounding box exceeds the budget is processed in a block of its own. """ data, _, _, positions = TestNThreads.make_inputs() aper = CircularAperture(positions[:6], r=7.0) stats1, stats2 = self.make_stats_pair(monkeypatch, data, aper, max_pixels=10) for prop in ('median', 'mean', 'sum', 'centroid'): assert_equal(getattr(stats1, prop), getattr(stats2, prop)) def test_indexing(self, monkeypatch): """ Test slicing, both before and after properties are computed. """ data, _, _, positions = TestNThreads.make_inputs() aper = CircularAperture(positions, r=7.0) stats1, stats2 = self.make_stats_pair(monkeypatch, data, aper) assert_equal(stats2[3:20].median, stats1.median[3:20]) assert_equal(stats2.median, stats1.median) assert_equal(stats2[3:20].median, stats1.median[3:20]) assert_equal(stats2[3:20].mean, stats1.mean[3:20]) assert_equal(stats2[5].mean, stats1.mean[5]) def test_mask_based_path(self, monkeypatch): """ Test that apertures without batch support are unaffected. """ data, _, _, positions = TestNThreads.make_inputs() aper = NoBatchCircularAperture(positions, r=7.0) stats1, stats2 = self.make_stats_pair(monkeypatch, data, aper) for prop in ('median', 'mean', 'sum'): assert_equal(getattr(stats1, prop), getattr(stats2, prop)) def test_peak_memory(self): """ Test that the peak memory is bounded by the block size instead of growing with the number of sources. """ rng = np.random.default_rng(0) data = rng.normal(10.0, 1.0, (1000, 1000)) n_sources = 3000 positions = rng.uniform(40, 960, (n_sources, 2)) aper = CircularAnnulus(positions, r_in=25.0, r_out=35.0) stats = ApertureStats(data, aper, sigma_clip=SigmaClip(sigma=3.0)) tracemalloc.start() try: median = stats.median median_err = stats.median_err peak = tracemalloc.get_traced_memory()[1] finally: tracemalloc.stop() assert np.all(np.isfinite(median)) assert np.all(np.isfinite(median_err)) # The packed buffers need up to 40 bytes per bounding-box pixel. # For all of the sources at once that is about 600 MB, while a # single block needs at most about 40 MB. n_bbox_pixels = n_sources * 71**2 assert n_bbox_pixels > 10 * _BLOCK_MAX_PIXELS assert peak < 2 * 40 * _BLOCK_MAX_PIXELS class TestInputDtypes: """ Tests that the compiled code reads float32 and float64 images and int32 and intp segmentation images directly, with the same results. All calculations are performed in float64, so float32 inputs give the same results as the same values input as float64, up to floating-point rounding. The compiler may fuse multiply-add operations differently in the float32 and float64 specializations of the compiled code, which changes some results by one ulp on some platforms (e.g., GCC on aarch64). """ # The relative tolerance for float32 versus float64 inputs (a few # ulps) rtol = 1e-13 @staticmethod def make_segmentation(shape, positions): """ Make a segmentation image with a square segment at each position and the matching labels. """ segm = np.zeros(shape, dtype=int) for label, (xpos, ypos) in enumerate(positions, start=1): xidx = int(np.clip(xpos, 0, shape[1] - 1)) yidx = int(np.clip(ypos, 0, shape[0] - 1)) segm[max(yidx - 4, 0):yidx + 5, max(xidx - 4, 0):xidx + 5] = label return segm, np.arange(1, len(positions) + 1) @pytest.mark.parametrize('mask_method', ['none', 'mask', 'correct']) @pytest.mark.parametrize('segm_dtype', [np.int16, np.int32, np.intp]) @pytest.mark.parametrize('dtype', [np.float32, np.float64]) def test_identical_results(self, dtype, segm_dtype, mask_method): """ Test that the statistics and the aperture photometry do not depend on the input dtypes. The float32 values are exactly representable as float64, so the results must agree with those for the same values input as float64 to within rounding. """ data, error, mask, positions = TestNThreads.make_inputs() data = data.astype(np.float32) error = error.astype(np.float32) segm, labels = self.make_segmentation(data.shape, positions) aper = CircularAperture(positions, r=7.0) sigma_clip = SigmaClip(sigma=3.0, maxiters=10) def make_stats(data, error, segm): kwargs = {'error': error, 'mask': mask, 'sigma_clip': sigma_clip} if mask_method != 'none': kwargs.update(segmentation_image=SegmentationImage(segm), labels=labels, mask_method=mask_method) return ApertureStats(data, aper, **kwargs) stats_ref = make_stats(data.astype(float), error.astype(float), segm.astype(np.intp)) stats = make_stats(data.astype(dtype), error.astype(dtype), segm.astype(segm_dtype)) TestNThreads.assert_stats_equal(stats_ref, stats, rtol=self.rtol) phot_ref = AperturePhotometry(data.astype(float), aper, error=error.astype(float), mask=mask) phot = AperturePhotometry(data.astype(dtype), aper, error=error.astype(dtype), mask=mask) assert_allclose(phot.flux, phot_ref.flux, rtol=self.rtol, atol=0) assert_allclose(phot.flux_err, phot_ref.flux_err, rtol=self.rtol, atol=0) def test_mixed_dtypes(self): """ Test float32 data with a float64 error array and noncontiguous float32 data. """ data, error, _, positions = TestNThreads.make_inputs() data32 = data.astype(np.float32) aper = CircularAperture(positions, r=7.0) stats_ref = ApertureStats(data32.astype(float), aper, error=error) data_fortran = np.asfortranarray(data32) assert not data_fortran.flags.c_contiguous # Big-endian float32 data (e.g., from a FITS file) with a # float64 error array. for data_in in (data32, data_fortran, data32.astype('>f4')): stats = ApertureStats(data_in, aper, error=error) for prop in ('sum', 'sum_err', 'median', 'std', 'centroid'): assert_allclose(getattr(stats, prop), getattr(stats_ref, prop), rtol=self.rtol, atol=0) # Big-endian float32 and float16 inputs are read as float32. error16 = error.astype(np.float16) stats_ref = ApertureStats(data32.astype(float), aper, error=error16.astype(float)) stats = ApertureStats(data32.astype('>f4'), aper, error=error16) for prop in ('sum', 'sum_err', 'median', 'std', 'centroid'): assert_allclose(getattr(stats, prop), getattr(stats_ref, prop), rtol=self.rtol, atol=0) @staticmethod def traced_peak(func): """ Return the peak traced memory, in bytes, while calling ``func``. """ tracemalloc.start() try: start = tracemalloc.get_traced_memory()[0] func() return tracemalloc.get_traced_memory()[1] - start finally: tracemalloc.stop() def test_float32_inputs_not_copied(self): """ Test that C-contiguous float32 inputs are used without making full-image float64 copies. """ rng = np.random.default_rng(0) data = rng.normal(10.0, 1.0, (1024, 1024)).astype(np.float32) error = np.ones(data.shape, dtype=np.float32) aper = CircularAperture(rng.uniform(20, 1000, (20, 2)), r=5.0) def run_stats(error): stats = ApertureStats(data, aper, error=error) return stats.median, stats.sum, stats.sum_err def run_photometry(error): return AperturePhotometry(data, aper, error=error).flux # A float64 copy of one image needs 8 bytes per pixel, while the # uint8 mask plane needs 1 byte per pixel. for func in (run_stats, run_photometry): assert self.traced_peak(lambda f=func: f(error)) < 4 * data.size # The images are copied when their dtypes differ. peak = self.traced_peak(lambda f=func: f(error.astype(float))) assert peak >= 8 * data.size def test_overridden_bbox_fallback(): """ Test that the bounding-box bounds honor an aperture subclass that overrides ``bbox``. The bounds are normally computed from the aperture's vectorized integer bounds without creating per-position BoundingBox objects. An overridden ``bbox`` must fall back to reading the objects. """ data = np.ones((60, 60)) positions = [(20.0, 20.0), (35.5, 24.2), (10.1, 40.7)] stats1 = ApertureStats(data, CircularAperture(positions, r=5.0)) stats2 = ApertureStats(data, _ExpandedBboxCircular(positions, r=5.0)) assert_equal(stats2.bbox_xmin, stats1.bbox_xmin - 1) assert_equal(stats2.bbox_xmax, stats1.bbox_xmax + 1) assert_equal(stats2.bbox_ymin, stats1.bbox_ymin - 1) assert_equal(stats2.bbox_ymax, stats1.bbox_ymax + 1) class TestCenterCutoutParity: """ The center-method cutouts reconstructed from the packed gather buffers must match the mask-based cutouts exactly, including the masked-pixel data values. """ @staticmethod def make_inputs(): """ Build a deterministic image (with nonfinite data and error values), error, mask, and positions (including partial-overlap and no-overlap positions). """ rng = np.random.default_rng(0) data = rng.normal(100.0, 5.0, (80, 90)) data[10, 10] = np.nan data[40, 41] = np.inf error = np.abs(rng.normal(5.0, 0.5, data.shape)) + 0.1 error[12, 12] = np.inf mask = np.zeros(data.shape, dtype=bool) mask[30:35, 30:35] = True positions = [(5.0, 5.0), (-20.0, -20.0), (33.0, 32.0), (45.5, 41.2), (88.0, 3.0), (12.3, 11.1)] return data, error, mask, positions @staticmethod def assert_cutout_lists_equal(fast_list, slow_list): """ Assert two per-source cutout lists are exactly equal, including the data values under the mask. """ for fast, slow in zip(fast_list, slow_list, strict=True): assert_equal(np.ma.getdata(fast), np.ma.getdata(slow)) assert_equal(np.ma.getmaskarray(fast), np.ma.getmaskarray(slow)) @staticmethod def get_cutouts(stats): """ Return the center-method cutout lists of an instance. """ return {'data': stats.data_cutout, 'variance': stats._variance_cutout_center, 'mask': stats._mask_cutout_center, 'weight': stats._weight_cutout_center} @pytest.mark.parametrize('use_sigma_clip', [False, True]) @pytest.mark.parametrize('use_error', [False, True]) def test_matches_mask_path(self, use_sigma_clip, use_error): """ Test that the buffer-reconstructed cutouts equal the mask-based cutouts, including nonfinite pixels, partial overlaps, and the no-overlap sentinel arrays. """ data, error, mask, positions = self.make_inputs() sigma_clip = (SigmaClip(sigma=2.0, maxiters=10) if use_sigma_clip else None) kwargs = {'error': error if use_error else None, 'mask': mask, 'sigma_clip': sigma_clip, 'local_bkg': np.linspace(-1.0, 1.0, len(positions))} aperture = CircularAperture(positions, r=6.0) fast = ApertureStats(data, aperture, **kwargs) assert fast._fast_cutouts_center is not None fast_cutouts = self.get_cutouts(fast) with patch.object(ApertureStats, '_batch_inputs', property(lambda _: None)): slow = ApertureStats(data, aperture, **kwargs) assert slow._fast_cutouts_center is None # The mask-based path multiplies nonfinite data values by # the boolean masks (NaN * 0), which emits a numpy # RuntimeWarning with np.errstate(invalid='ignore'): slow_cutouts = self.get_cutouts(slow) for key in fast_cutouts: if not use_error and key == 'variance': assert all(value is None for value in fast_cutouts[key]) assert all(value is None for value in slow_cutouts[key]) continue self.assert_cutout_lists_equal(fast_cutouts[key], slow_cutouts[key]) @pytest.mark.parametrize('mask_method', ['mask', 'source_only', 'background_only']) def test_segmentation_masking(self, mask_method): """ Test that segmentation-excluded pixels land in the cutout mask identically to the mask-based path. """ data = np.ones((50, 50)) segm = np.zeros((50, 50), dtype=int) data[18:25, 18:25] = 10.0 segm[18:25, 18:25] = 1 data[20:25, 26:32] = 100.0 segm[20:25, 26:32] = 2 aperture = CircularAperture([(21.0, 21.0), (28.0, 22.0)], r=8.0) kwargs = {'segmentation_image': SegmentationImage(segm), 'labels': [1, 2], 'mask_method': mask_method} fast = ApertureStats(data, aperture, **kwargs) assert fast._fast_cutouts_center is not None fast_data = fast.data_cutout with patch.object(ApertureStats, '_batch_inputs', property(lambda _: None)): slow = ApertureStats(data, aperture, **kwargs) slow_data = slow.data_cutout self.assert_cutout_lists_equal(fast_data, slow_data) def test_correct_mode_fallback(self): """ Test that mask_method='correct' uses the mask-based cutout path (the mirrored error values are not recoverable from the packed buffers) and still produces the corrected cutouts. """ data = np.ones((50, 50)) segm = np.zeros((50, 50), dtype=int) data[18:25, 18:25] = 10.0 segm[18:25, 18:25] = 1 data[20:25, 26:32] = 100.0 segm[20:25, 26:32] = 2 aperture = CircularAperture([(21.0, 21.0)], r=8.0) stats = ApertureStats(data, aperture, segmentation_image=SegmentationImage(segm), labels=[1], mask_method='correct') assert stats._fast_gather is not None assert stats._fast_cutouts_center is None # The bright neighbor pixel values must not leak into the # cutout (they are replaced by their mirrored values) cutout = stats.data_cutout[0] assert np.ma.getdata(cutout).max() <= 10.0 class TestSumErrPartialPixelWeights: """ Tests that ``sum_err`` weights the pixel variances by the squared overlap fractions. The reported sum is sum(w * data) over the aperture pixels, where ``w`` is the pixel overlap fraction. With independent pixel errors, its variance is sum(w**2 * sigma**2), so boundary pixels must contribute their variance weighted by the squared fraction. """ position = (12.2, 11.7) radius = 3.3 @staticmethod def _expected_error(aperture, error): """ Compute sqrt(sum(w**2 * sigma**2)) from the exact aperture mask. """ apermask = aperture.to_mask(method='exact') slc_large, slc_small = apermask.get_overlap_slices(error.shape) weights = apermask.data[slc_small] return np.sqrt(np.sum(weights**2 * error[slc_large] ** 2)) @pytest.fixture(name='scene') def fixture_scene(self): """ A deterministic uniform image and nonuniform error array. The data are uniform so that sigma clipping never rejects a pixel, keeping the expected error identical for the clipped and unclipped code paths. """ rng = np.random.default_rng(seed=456) data = np.ones((25, 25)) error = rng.uniform(0.5, 1.5, (25, 25)) return data, error def test_batch_path(self, scene): data, error = scene aperture = CircularAperture(self.position, self.radius) apstats = ApertureStats(data, aperture, error=error) expected = self._expected_error(aperture, error) assert_allclose(apstats.sum_err, expected) def test_mask_path(self, scene): data, error = scene aperture = NoBatchCircularAperture(self.position, self.radius) apstats = ApertureStats(data, aperture, error=error) expected = self._expected_error(aperture, error) assert_allclose(apstats.sum_err, expected) def test_sigma_clip_batch_path(self, scene): data, error = scene aperture = CircularAperture(self.position, self.radius) sigma_clip = SigmaClip(sigma=3.0, maxiters=10) apstats = ApertureStats(data, aperture, error=error, sigma_clip=sigma_clip) expected = self._expected_error(aperture, error) assert_allclose(apstats.sum_err, expected) def test_error_sum_cutout(self, scene): """ The ``error_sum_cutout`` values are the pixel errors weighted by the overlap fractions, so that the quadrature sum of the cutout equals ``sum_err``. """ data, error = scene aperture = CircularAperture(self.position, self.radius) apstats = ApertureStats(data, aperture, error=error) apermask = aperture.to_mask(method='exact') slc_large, slc_small = apermask.get_overlap_slices(error.shape) expected = apermask.data[slc_small] * error[slc_large] cutout = apstats.error_sum_cutout assert_allclose(cutout.filled(0.0), expected) assert_allclose(np.sqrt(np.sum(cutout.filled(0.0) ** 2)), apstats.sum_err) def _make_rotated_tan_wcs(parity, rotation_deg, *, scale=0.25): """ Make a TAN WCS whose CD matrix is a rotation of an axis-aligned frame with the given parity. With ``parity=-1`` and no rotation, +x points West and +y points North (the standard "North up, East left" orientation). With ``parity=1`` the x-axis is flipped so that +x points East. """ wcs = WCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = [15.0, 15.0] wcs.wcs.crval = [150.0, 20.0] phi = np.deg2rad(rotation_deg) rotation = np.array([[np.cos(phi), -np.sin(phi)], [np.sin(phi), np.cos(phi)]]) wcs.wcs.cd = (scale / 3600.0) * rotation @ np.diag([parity, 1.0]) return wcs def _wrap_m90_p90(angle_deg): """ Wrap angles in degrees to the range (-90, 90]. """ return -((-angle_deg + 90.0) % 180.0) + 90.0 class TestSkyOrientation: """ Tests for the ``sky_orientation`` property. """ @staticmethod def _elongated_source_stats(theta_deg, wcs, *, scalar=False): """ Make an ``ApertureStats`` of an elongated Gaussian centered near the WCS reference pixel, measured within a circular aperture that contains the whole source. """ yy, xx = np.mgrid[0:31, 0:31] theta = np.deg2rad(theta_deg) data = Gaussian2D(500.0, 15.2, 15.6, 3.0, 1.5, theta)(xx, yy) positions = (15.0, 15.0) if scalar else [(15.0, 15.0)] aperture = CircularAperture(positions, 12.0) return ApertureStats(data, aperture, wcs=wcs) @pytest.mark.parametrize('theta_deg', [-60.0, 10.0, 45.0, 80.0]) @pytest.mark.parametrize(('parity', 'rotation_deg'), [(-1, 0.0), (1, 0.0), (-1, 30.0), (-1, -75.0), (1, 120.0)]) def test_sky_orientation(self, theta_deg, parity, rotation_deg): """ Test the sky orientation against the pixel orientation mapped through the CD matrix. Near the reference pixel of a TAN projection the local Jacobian is the CD matrix, so the sky position angle of the pixel major-axis direction is known in closed form. """ wcs = _make_rotated_tan_wcs(parity, rotation_deg) apstats = self._elongated_source_stats(theta_deg, wcs) sky_orient = apstats.sky_orientation assert sky_orient.unit == u.deg assert sky_orient.shape == (1,) assert np.all(sky_orient > -90 * u.deg) assert np.all(sky_orient <= 90 * u.deg) # The major-axis direction on the sky is the CD matrix (columns # x and y, rows xi=East and eta=North) applied to the pixel # direction. The position angle is measured from North toward # East. pix_orient = np.deg2rad(apstats.orientation.to_value(u.deg)[0]) pix_dir = np.array([np.cos(pix_orient), np.sin(pix_orient)]) xi, eta = wcs.wcs.cd @ pix_dir expected = _wrap_m90_p90(np.rad2deg(np.arctan2(xi, eta))) assert_allclose(sky_orient.to_value(u.deg)[0], expected, atol=1e-4) # A sliced scalar instance collapses to a scalar Quantity single = apstats[0] assert single.sky_orientation.shape == () assert_allclose(single.sky_orientation, sky_orient[0]) @pytest.mark.parametrize(('parity', 'rotation_deg'), [(-1, 0.0), (1, 0.0), (-1, 30.0), (-1, -75.0), (1, 120.0)]) def test_sky_aperture_theta_convention(self, parity, rotation_deg): """ Test that the sky orientation follows the same position-angle convention as the ``theta`` parameter of the sky-based apertures. A sky ellipse built with ``theta=sky_orientation`` must have the pixel ``orientation`` after conversion to a pixel aperture. """ wcs = _make_rotated_tan_wcs(parity, rotation_deg) apstats = self._elongated_source_stats(40.0, wcs) sky_aperture = SkyEllipticalAperture(apstats.sky_centroid[0], 3.0 * u.arcsec, 1.5 * u.arcsec, theta=apstats.sky_orientation[0]) pix_theta = sky_aperture.to_pixel(wcs).theta.to_value(u.deg) expected = apstats.orientation.to_value(u.deg)[0] assert_allclose(_wrap_m90_p90(pix_theta), expected, atol=1e-4) def test_scalar_aperture(self): """ Test that an instance built from a scalar aperture gives the same scalar result as slicing a one-element instance. """ wcs = _make_rotated_tan_wcs(-1, 30.0) apstats = self._elongated_source_stats(30.0, wcs) scalar_stats = self._elongated_source_stats(30.0, wcs, scalar=True) assert scalar_stats.isscalar assert scalar_stats.sky_orientation.shape == () assert_allclose(scalar_stats.sky_orientation, apstats.sky_orientation[0]) def test_north_up_east_left(self): """ Test the closed-form relation for the standard North-up, East-left orientation, where the position angle is the pixel orientation minus 90 degrees. """ wcs = _make_rotated_tan_wcs(-1, 0.0) apstats = self._elongated_source_stats(30.0, wcs) expected = _wrap_m90_p90(apstats.orientation.to_value(u.deg) - 90.0) assert_allclose(apstats.sky_orientation.to_value(u.deg), expected, atol=1e-4) # A flipped parity (East right) mirrors the position angle wcs_flip = _make_rotated_tan_wcs(1, 0.0) apstats_flip = self._elongated_source_stats(30.0, wcs_flip) expected_flip = _wrap_m90_p90( 90.0 - apstats_flip.orientation.to_value(u.deg)) assert_allclose(apstats_flip.sky_orientation.to_value(u.deg), expected_flip, atol=1e-4) def test_nan(self): """ Test that a fully masked aperture has a NaN sky orientation while an unmasked aperture is finite. """ wcs = _make_rotated_tan_wcs(-1, 0.0) yy, xx = np.mgrid[0:31, 0:31] data = (Gaussian2D(500.0, 8.0, 8.0, 3.0, 1.5, 0.5)(xx, yy) + Gaussian2D(500.0, 22.0, 22.0, 3.0, 1.5, 0.5)(xx, yy)) mask = np.zeros(data.shape, dtype=bool) mask[:16, :16] = True aperture = CircularAperture([(8.0, 8.0), (22.0, 22.0)], 6.0) apstats = ApertureStats(data, aperture, wcs=wcs, mask=mask) sky_orient = apstats.sky_orientation.to_value(u.deg) assert np.isnan(apstats.orientation[0]) assert np.isnan(sky_orient[0]) assert np.isfinite(sky_orient[1]) def test_no_wcs(self): """ Test that sky_orientation is None without a wcs. """ apstats = self._elongated_source_stats(30.0, None) assert np.all(apstats.sky_orientation == np.array(None)) assert apstats[0].sky_orientation is None def test_table_column(self): """ Test the sky_orientation column in to_table. """ wcs = _make_rotated_tan_wcs(-1, 0.0) apstats = self._elongated_source_stats(30.0, wcs) columns = ['id', 'orientation', 'sky_orientation'] tbl = apstats.to_table(columns=columns) assert tbl.colnames == columns assert tbl['sky_orientation'].unit == u.deg assert_allclose(tbl['sky_orientation'], apstats.sky_orientation) # The aperture radii give resolved sources (6.0), sources of a few # pixels (1.2), and single-pixel sources (0.6). AGREEMENT_RADII = (6.0, 1.2, 0.6) @pytest.fixture(scope='class') def agreement_inputs(request): """ Build the shared data, mask, WCS, apertures, and segmentation image on the test class. There is one multiposition aperture per radius. The segments are the "center"-method aperture footprints, labeled in aperture order, then position order. """ cls = request.cls rng = np.random.default_rng(0) shape = (120, 160) yy, xx = np.mgrid[:shape[0], :shape[1]] data = rng.normal(0.0, 1.0, shape) apertures = [] for i, radius in enumerate(AGREEMENT_RADII): xypos = [] for xcen in range(20, 160, 20): # The offsets keep a pixel center within the smallest # aperture. xpos = xcen + rng.uniform(-0.3, 0.3) ypos = 20.0 + 40.0 * i + rng.uniform(-0.3, 0.3) model = Gaussian2D(rng.uniform(20.0, 200.0), xpos, ypos, rng.uniform(1.0, 4.0), rng.uniform(1.0, 4.0), rng.uniform(0.0, np.pi)) data += model(xx, yy) xypos.append((xpos, ypos)) apertures.append(CircularAperture(xypos, radius)) # The first source of each aperture is fully masked, the second is # partially masked, and the third has a NaN pixel. mask = np.zeros(shape, dtype=bool) for aperture in apertures: xpos, ypos = np.round(aperture.positions).astype(int).T mask[ypos[0] - 8:ypos[0] + 9, xpos[0] - 8:xpos[0] + 9] = True mask[ypos[1], xpos[1] + 1] = True data[ypos[2], xpos[2] + 1] = np.nan segm = np.zeros(shape, dtype=int) label = 0 for aperture in apertures: for aperture_mask in aperture.to_mask(method='center'): label += 1 segm[aperture_mask.to_image(shape) > 0] = label cls.data = data cls.mask = mask cls.wcs = make_wcs(shape) cls.apertures = apertures cls.segm = SegmentationImage(segm) @pytest.mark.usefixtures('agreement_inputs') class TestSourceCatalogAgreement: """ Tests that ApertureStats and SourceCatalog give the same centroid and shape properties for sources that cover the same pixels. The segments are the "center"-method aperture footprints. The two classes differ only in that SourceCatalog sets negative data values to zero. """ # The image moments are excluded because each class measures them # from the origin of its own cutout. PROPERTIES = ('centroid', 'covariance', 'covariance_eigvals', 'semimajor_axis', 'semiminor_axis', 'fwhm', 'orientation', 'sky_orientation', 'eccentricity', 'elongation', 'ellipticity', 'covariance_xx', 'covariance_yy', 'covariance_xy', 'ellipse_cxx', 'ellipse_cyy', 'ellipse_cxy') def _compare(self, aperture_data, catalog_data): """ Compare the properties of the two classes and return them. """ catalog = SourceCatalog(catalog_data, self.segm, mask=self.mask, wcs=self.wcs) n_positions = len(self.apertures[0]) all_stats = [] for i, aperture in enumerate(self.apertures): stats = ApertureStats(aperture_data, aperture, mask=self.mask, wcs=self.wcs) cat = catalog[i * n_positions:(i + 1) * n_positions] for name in self.PROPERTIES: value = u.Quantity(getattr(stats, name)).value expected = u.Quantity(getattr(cat, name)).value assert_equal(np.isnan(value), np.isnan(expected)) assert_allclose(value, expected, rtol=1e-10, atol=1e-10, err_msg=name) assert_equal( (stats.flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0, (cat.flags & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE) != 0) all_stats.append((stats, cat)) return all_stats def test_non_negative_data(self): """ Test that the properties agree for nonnegative data, where the SourceCatalog zeroing does nothing. """ data = np.where(self.data < 0, 0.0, self.data) all_stats = self._compare(data, data) # The single-pixel sources exercise the regularization. stats, _ = all_stats[2] assert np.all(stats.flags[1:] & APERTURE_FLAGS.SINGULAR_COVARIANCE) @staticmethod def _regularized_mask(obj): """ Return a mask of the sources whose covariance is finite and differs from the raw covariance computed from the public central moments. """ mu = np.asarray(obj.moments_central) raw = np.stack((mu[:, 0, 2], mu[:, 1, 1], mu[:, 1, 1], mu[:, 2, 0]), axis=1).reshape(-1, 2, 2) with np.errstate(divide='ignore', invalid='ignore'): raw = raw / mu[:, 0, 0][:, np.newaxis, np.newaxis] cov = u.Quantity(obj.covariance).value finite = np.all(np.isfinite(cov), axis=(1, 2)) return finite & np.any(cov != raw, axis=(1, 2)) def test_singular_flag_marks_regularized_sources(self): """ Test that the singular_covariance flag marks exactly the sources whose covariance was regularized and is finite, in each class. The fixture apertures give resolved, unresolved, and fully masked sources. A row of apertures on pure noise, where the negative values are kept, adds sources with a positive net flux whose covariance is invalid (NaN). Those must not be flagged. """ cat = SourceCatalog(self.data, self.segm, mask=self.mask, wcs=self.wcs) singular = (cat.flags & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE) != 0 assert_equal(singular, self._regularized_mask(cat)) assert np.any(singular) assert not np.all(singular) noise_aperture = CircularAperture([(x, 110.0) for x in range(10, 160, 5)], 1.2) for aperture in (*self.apertures, noise_aperture): stats = ApertureStats(self.data, aperture, mask=self.mask, wcs=self.wcs) singular = (stats.flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 assert_equal(singular, self._regularized_mask(stats)) # The noise apertures have no unresolved sources, but several # have a positive net flux and a NaN covariance assert not np.any(singular) undefined = (stats.flags & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 nan_cov = np.any(np.isnan(stats.covariance.value), axis=(1, 2)) invalid = (stats.moments[:, 0, 0] > 0) & nan_cov assert np.any(invalid) assert np.all(undefined[invalid]) def test_negative_data_zeroing(self): """ Test that zeroing the negative data values is the only numerical difference between the two classes. """ data = self.data assert np.any(data < 0) clipped = np.where(data < 0, 0.0, data) self._compare(clipped, data) # The properties differ without the zeroing. stats = ApertureStats(data, self.apertures[0], mask=self.mask, wcs=self.wcs) cat = SourceCatalog(data, self.segm, mask=self.mask, wcs=self.wcs)[:len(stats)] diff = np.abs(stats.fwhm[1:] - cat.fwhm[1:]) assert np.any(diff > 1e-3 * u.pix) def test_fully_masked_source(self): """ Test a fully masked source. The shape properties are NaN and the undefined-shape flag is set in both classes. The zeroth central moment equals the zeroth raw moment, which is zero. The other central moments are NaN because the centroid is undefined. """ data = np.where(self.data < 0, 0.0, self.data) for stats, cat in self._compare(data, data): assert np.all(np.isnan(stats.covariance[0])) assert np.all(np.isnan(cat.covariance[0])) assert_equal(stats.moments_central[0], cat.moments_central[0]) assert stats.moments_central[0, 0, 0] == 0.0 assert np.all(np.isnan(stats.moments_central[0].ravel()[1:])) assert stats.flags[0] & APERTURE_FLAGS.ALL_MASKED assert stats.flags[0] & APERTURE_FLAGS.UNDEFINED_SHAPE assert cat.flags[0] & SEGMENTATION_FLAGS.ALL_MASKED assert cat.flags[0] & SEGMENTATION_FLAGS.UNDEFINED_SHAPE def test_default_columns_deprecated(): """ Test that the deprecated module-level DEFAULT_COLUMNS constant returns a copy of the 3.0 default to_table() columns, which did not include the flags column. """ data = np.ones((10, 10)) apstats = ApertureStats(data, CircularAperture((5, 5), r=2)) match = 'DEFAULT_COLUMNS is deprecated' with pytest.warns(PhotutilsDeprecationWarning, match=match): columns = stats_module.DEFAULT_COLUMNS assert 'flags' not in columns assert columns == [column for column in apstats.default_columns if column != 'flags'] assert apstats.to_table(columns=columns).colnames == columns columns.append('foo') with pytest.warns(PhotutilsDeprecationWarning, match=match): assert 'foo' not in stats_module.DEFAULT_COLUMNS assert 'foo' not in apstats.default_columns match = 'has no attribute' with pytest.raises(AttributeError, match=match): _ = stats_module.NOT_AN_ATTRIBUTE astropy-photutils-24be529/photutils/aperture/tests/test_stats_flags.py000066400000000000000000001121151526452531300265600ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the ApertureStats quality flags. """ from unittest.mock import patch import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose, assert_array_equal from photutils.aperture import (APERTURE_FLAGS, ApertureStats, CircularAnnulus, CircularAperture, RectangularAperture) from photutils.aperture.tests.conftest import UNIT_SHAPE from photutils.segmentation import SegmentationImage def _stats_flags(data, aperture, **kwargs): return ApertureStats(data, aperture, **kwargs).flags def _force_mask_path(): """ Return a context manager that disables the fast Cython batch path. """ return patch.object(ApertureStats, '_batch_inputs', property(lambda _self: None)) @pytest.fixture(params=[True, False], ids=['fast', 'maskpath']) def maybe_mask_path(request): """ Run a test with both the fast batch path and the mask-based path. """ if request.param: yield else: with _force_mask_path(): yield def _single_pixel_data(): """ Return data with a single bright pixel, giving a source whose covariance matrix is singular (zero spatial extent). """ data = np.zeros(UNIT_SHAPE) data[12, 12] = 100.0 return data def _stats_with_injected_covariance(cov_xx, cov_yy, cov_xy): """ Return a length-1 array ``ApertureStats`` whose central moments are overridden so that the source covariance matrix has the given entries (with unit total weight). This exercises the singular-covariance criterion directly for cases that are awkward to realize from pixel data (rank-1 degeneracy and a covariance matrix that is not positive semidefinite). """ data = _single_pixel_data() aper = CircularAperture([(12.0, 12.0)], r=5.0) stats = ApertureStats(data, aper) moments = np.zeros((1, 4, 4)) moments[0, 0, 0] = 1.0 # total weight (m00) moments[0, 2, 0] = cov_xx # normalized -> covariance_xx moments[0, 0, 2] = cov_yy # normalized -> covariance_yy moments[0, 1, 1] = cov_xy # normalized -> covariance_xy stats.moments_central = moments return stats class TestOverlapFlags: """ Tests for the no_overlap, partial_overlap, and no_pixels flags. """ @pytest.mark.usefixtures('maybe_mask_path') def test_no_flags(self, unit_data): """ Test that a clean interior source has no flags set. """ data = unit_data aper = CircularAperture((12, 12), r=3.0) stats = ApertureStats(data, aper) assert stats.flags == 0 # scalar aperture gives a scalar flag @pytest.mark.usefixtures('maybe_mask_path') def test_overlap_flags(self, unit_data): """ Test the no_overlap, partial_overlap, and no_pixels flags. """ data = unit_data aper = CircularAperture([(12.0, 12.0), (0.0, 12.0), (-50.0, 12.0)], r=3.0) stats = ApertureStats(data, aper) assert stats.flags[0] == 0 assert stats.flags[1] == APERTURE_FLAGS.PARTIAL_OVERLAP assert stats.flags[2] == (APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS) @pytest.mark.usefixtures('maybe_mask_path') def test_no_overlap_needs_both_footprints(self, unit_data): """ Test that no_overlap is not set when the sum footprint overlaps the data and the "center" footprint does not. """ data = unit_data aper = CircularAperture((-2.4, -2.4), r=3.0) stats = ApertureStats(data, aper) assert stats.flags == (APERTURE_FLAGS.PARTIAL_OVERLAP | APERTURE_FLAGS.NO_PIXELS) assert stats.sum > 0.0 assert np.isnan(stats.mean) stats = ApertureStats(data, aper, sum_method='center') assert stats.flags == (APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS) assert np.isnan(stats.sum) @pytest.mark.usefixtures('maybe_mask_path') def test_no_pixels(self, unit_data): """ Test that an empty "center" footprint sets no_pixels even when the default exact-method sum footprint is populated (the per-footprint flag bits are combined with OR). """ data = unit_data # Nearest pixel centers are sqrt(0.5) ~ 0.707 away aper = CircularAperture((12.5, 12.5), r=0.4) stats = ApertureStats(data, aper) assert stats.flags == APERTURE_FLAGS.NO_PIXELS assert stats.sum > 0.0 # the exact-method sum is finite assert np.isnan(stats.mean) # center statistics are undefined class TestMaskedAndNonFiniteFlags: """ Tests for the masked_pixels, all_masked, and nonfinite flags. """ @pytest.mark.usefixtures('maybe_mask_path') def test_masked_pixels(self, unit_data): """ Test the masked_pixels and all_masked flags. A fully masked aperture also has an undefined shape. """ data = unit_data aper = CircularAperture((12, 12), r=3.0) mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[12, 12] = True assert _stats_flags(data, aper, mask=mask) == APERTURE_FLAGS.MASKED_PIXELS # Masked pixel inside the bounding box but outside the aperture mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[9, 9] = True assert _stats_flags(data, aper, mask=mask) == 0 # Fully masked aperture mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[8:17, 8:17] = True assert _stats_flags(data, aper, mask=mask) == ( APERTURE_FLAGS.MASKED_PIXELS | APERTURE_FLAGS.ALL_MASKED | APERTURE_FLAGS.UNDEFINED_SHAPE) @pytest.mark.usefixtures('maybe_mask_path') def test_sum_footprint_masked(self, unit_data, unit_mask): """ Test that a masked boundary pixel touched only by the exact-method sum footprint (its center lies exactly on the aperture boundary) sets masked_pixels in the merged flags. """ data = unit_data aper = CircularAperture((12, 12), r=3.0) mask = unit_mask mask[15, 12] = True # center at distance exactly 3.0 (boundary) stats = ApertureStats(data, aper, mask=mask) assert stats.flags == APERTURE_FLAGS.MASKED_PIXELS # With sum_method='center' both footprints exclude the pixel stats = ApertureStats(data, aper, mask=mask, sum_method='center') assert stats.flags == 0 @pytest.mark.usefixtures('maybe_mask_path') def test_non_finite_data(self, unit_mask): """ Test the non_finite_data flag. In ApertureStats, nonfinite data values are automatically masked, so they contribute to all_masked (but not to masked_pixels, which reflects only the input mask). """ data = np.ones(UNIT_SHAPE) data[12, 12] = np.nan aper = CircularAperture((12, 12), r=3.0) assert _stats_flags(data, aper) == APERTURE_FLAGS.NON_FINITE_DATA # All-NaN aperture: auto-masked, so also all_masked data = np.full(UNIT_SHAPE, np.nan) assert _stats_flags(data, aper) == (APERTURE_FLAGS.NON_FINITE_DATA | APERTURE_FLAGS.ALL_MASKED | APERTURE_FLAGS.UNDEFINED_SHAPE) # A pixel that is both input-masked and nonfinite counts only as # masked data = np.ones(UNIT_SHAPE) data[12, 12] = np.nan mask = unit_mask mask[12, 12] = True assert _stats_flags(data, aper, mask=mask) == APERTURE_FLAGS.MASKED_PIXELS @pytest.mark.usefixtures('maybe_mask_path') def test_non_finite_error(self, unit_data): """ Test the non_finite_error flag (evaluated on the sum footprint). """ data = unit_data error = np.ones(UNIT_SHAPE) error[12, 12] = np.nan aper = CircularAperture((12, 12), r=3.0) stats = ApertureStats(data, aper, error=error) assert stats.flags == APERTURE_FLAGS.NON_FINITE_ERROR assert ApertureStats(data, aper).flags == 0 class TestSigmaClipFlags: """ Tests for the sigma_clipped, all_clipped, and too_few_pixels flags. """ @pytest.mark.usefixtures('maybe_mask_path') def test_sigma_clipped(self): """ Test the sigma_clipped flag. """ rng = np.random.default_rng(0) data = rng.normal(1.0, 0.1, size=UNIT_SHAPE) data[12, 12] = 1000.0 # outlier aper = CircularAperture((12, 12), r=3.0) sigclip = SigmaClip(sigma=3.0, maxiters=10) flags = _stats_flags(data, aper, sigma_clip=sigclip) assert flags == APERTURE_FLAGS.SIGMA_CLIPPED # Without clipping, the sigma_clipped flag is not set. The # unclipped outlier dominates the moments, making the source # point-like, so the singular_covariance bit is set instead. assert _stats_flags(data, aper) == APERTURE_FLAGS.SINGULAR_COVARIANCE def test_all_clipped(self, unit_data): """ Test the all_clipped flag using a SigmaClip subclass that rejects every pixel via the mask-based path. """ class _ClipAll(SigmaClip): def __call__(self, data, **kwargs): # noqa: ARG002 return np.ma.masked_array(data, mask=np.ones(data.shape, dtype=bool)) data = unit_data aper = CircularAperture((12, 12), r=3.0) # A callable cenfunc is not supported by the fast clipping kernel, # forcing the mask-based path sigclip = _ClipAll(cenfunc=np.ma.median) stats = ApertureStats(data, aper, sigma_clip=sigclip) assert stats.flags == (APERTURE_FLAGS.SIGMA_CLIPPED | APERTURE_FLAGS.ALL_CLIPPED) assert np.isnan(stats.mean) def test_all_clipped_fast_path(self): """ Test the all_clipped flag when the fast clipping kernel rejects every pixel. Sigma clipping two very different values with a tiny sigma converges to an empty set. The kernel then keeps the bounds from the last iteration that still had data, so every pixel is clipped. This matches astropy's fast C sigma clipping. """ data = np.zeros((25, 25)) data[12, 13] = 100.0 # The r=0.6 center footprint contains exactly the two pixels # (12, 12) and (12, 13), with values 0 and 100. aper = CircularAperture((12.5, 12), r=0.6) sigclip = SigmaClip(sigma=0.1, maxiters=10) stats = ApertureStats(data, aper, sigma_clip=sigclip) assert stats._batch_inputs is not None assert stats.flags == (APERTURE_FLAGS.SIGMA_CLIPPED | APERTURE_FLAGS.ALL_CLIPPED) assert np.isnan(stats.mean) @pytest.mark.usefixtures('maybe_mask_path') def test_too_few_pixels_single_pixel(self, unit_data): """ Test that the too_few_pixels flag is set for a single valid pixel, for which the standard errors are undefined. """ data = unit_data stats = ApertureStats(data, CircularAperture((12, 12), r=0.6)) assert stats.center_aper_area.value == 1 assert stats.flags & APERTURE_FLAGS.TOO_FEW_PIXELS assert np.isnan(stats.mean_err) assert np.isnan(stats.median_err) # Two valid pixels define the standard errors aper = RectangularAperture((12.5, 12), w=1.8, h=0.8) stats = ApertureStats(data, aper) assert stats.center_aper_area.value == 2 assert not stats.flags & APERTURE_FLAGS.TOO_FEW_PIXELS assert np.isfinite(stats.mean_err) @pytest.mark.usefixtures('maybe_mask_path') def test_too_few_pixels(self, unit_data): """ Test the too_few_pixels flag with ddof. """ data = unit_data # The r=1.1 center footprint contains exactly 5 pixels aper = CircularAperture((12, 12), r=1.1) stats = ApertureStats(data, aper, ddof=5) assert stats.flags == APERTURE_FLAGS.TOO_FEW_PIXELS assert np.isnan(stats.var) assert np.isnan(stats.std) assert _stats_flags(data, aper, ddof=1) == 0 assert _stats_flags(data, aper, ddof=0) == 0 class TestSegmentationFlags: """ Tests for the neighbor_pixels and uncorrected_pixels flags. """ @pytest.mark.parametrize('mask_method', ['mask', 'source_only', 'correct']) @pytest.mark.usefixtures('maybe_mask_path') def test_neighbor_pixels(self, unit_data, mask_method): """ Test the neighbor_pixels flag for all segmentation mask methods. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=int) segm[10:15, 10:15] = 1 segm[12, 14] = 2 # neighbor pixel inside the aperture aper = CircularAperture((12, 12), r=3.0) flags = _stats_flags(data, aper, segmentation_image=SegmentationImage(segm), labels=1, mask_method=mask_method) assert flags == APERTURE_FLAGS.NEIGHBOR_PIXELS @pytest.mark.usefixtures('maybe_mask_path') def test_background_only_neighbor_pixels(self, unit_data): """ Test the neighbor_pixels flag with mask_method='background_only', where every labeled pixel counts as a neighbor pixel. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=int) segm[11:14, 11:14] = 1 # a source inside the aperture aper = CircularAperture((12, 12), r=3.0) flags = _stats_flags(data, aper, segmentation_image=SegmentationImage(segm), mask_method='background_only') assert flags == APERTURE_FLAGS.NEIGHBOR_PIXELS # An aperture on pure background is not flagged aper = CircularAperture((4, 4), r=3.0) flags = _stats_flags(data, aper, segmentation_image=SegmentationImage(segm), mask_method='background_only') assert flags == 0 @pytest.mark.usefixtures('maybe_mask_path') def test_uncorrected_pixels(self, unit_data): """ Test the uncorrected_pixels flag with mask_method='correct'. """ data = unit_data segm = np.zeros(UNIT_SHAPE, dtype=int) segm[10:15, 10:15] = 1 segm[12, 14] = 2 segm[12, 10] = 2 # the mirror is also a neighbor: uncorrectable aper = CircularAperture((12, 12), r=3.0) flags = _stats_flags(data, aper, segmentation_image=SegmentationImage(segm), labels=1, mask_method='correct') assert flags == (APERTURE_FLAGS.NEIGHBOR_PIXELS | APERTURE_FLAGS.UNCORRECTED_PIXELS) class TestMaskPathParity: """ The mask-based code path must set the same flags as the batch driver. """ def test_parity(self, unit_mask): """ Test that the fast batch path and the mask-based path produce identical flags for a mix of conditions. """ rng = np.random.default_rng(1) data = rng.normal(1.0, 0.1, size=UNIT_SHAPE) data[13, 12] = np.nan data[5, 5] = 100.0 # sigma-clip outlier error = np.ones(UNIT_SHAPE) error[10, 12] = np.inf mask = unit_mask mask[12, 12] = True xy = [(12.0, 12.0), (5.0, 5.0), (0.0, 12.0), (-50.0, 12.0), (24.5, 24.5), (0.2, 12.0), (12.5, 3.5)] aper = CircularAperture(xy, r=3.0) for kwargs in ({}, {'error': error, 'mask': mask}, {'sigma_clip': SigmaClip(sigma=3.0)}, {'sum_method': 'center'}, {'ddof': 1}): fast = ApertureStats(data, aper, **kwargs) assert fast._batch_inputs is not None with _force_mask_path(): slow = ApertureStats(data, aper, **kwargs) assert slow._batch_inputs is None assert_array_equal(fast.flags, slow.flags) class TestFlagsAPI: """ Tests for how the flags are exposed by ApertureStats. """ def test_flags_in_table_and_properties(self, unit_data): """ Test that flags appears in the properties list and the default to_table() columns. """ data = unit_data aper = CircularAperture([(12, 12), (-50, 12)], r=3.0) stats = ApertureStats(data, aper) assert 'flags' in stats.properties assert 'sum_flags' not in stats.properties tbl = stats.to_table() assert 'flags' in tbl.colnames assert 'sum_flags' not in tbl.colnames expected = [0, APERTURE_FLAGS.NO_OVERLAP | APERTURE_FLAGS.NO_PIXELS] assert_array_equal(tbl['flags'], expected) def test_flags_deterministic(self, unit_data, unit_mask): """ Test that flags is stable regardless of property access order and includes the shape bits without prior shape access. """ data = unit_data.copy() data[12, 12] = -100.0 # nonpositive net flux for source 1 mask = unit_mask mask[18, 18] = True aper = CircularAperture([(12.0, 12.0), (18.0, 18.0)], r=3.0) stats = ApertureStats(data, aper, mask=mask) flags1 = stats.flags.copy() assert (flags1[0] & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 assert (flags1[1] & APERTURE_FLAGS.MASKED_PIXELS) != 0 _ = stats.semimajor_axis _ = stats.eccentricity _ = stats.sum assert_array_equal(stats.flags, flags1) def test_flags_slicing(self, unit_data, unit_mask): """ Test that flags are sliced correctly by __getitem__. """ data = unit_data mask = unit_mask mask[12, 12] = True aper = CircularAperture([(12, 12), (0.0, 12.0)], r=3.0) stats = ApertureStats(data, aper, mask=mask) flags = stats.flags # evaluate before slicing assert stats[0].flags == flags[0] assert stats[1].flags == flags[1] # Also test slicing before evaluation stats2 = ApertureStats(data, aper, mask=mask) assert stats2[1].flags == flags[1] def test_decode_flags(self, unit_data, unit_mask): """ Test the decode_flags convenience method. """ data = unit_data mask = unit_mask mask[12, 12] = True aper = CircularAperture([(12.0, 12.0), (0.0, 12.0)], r=3.0) stats = ApertureStats(data, aper, mask=mask) decoded = stats.decode_flags() assert decoded == {1: ['masked_pixels'], 2: ['partial_overlap']} assert all(type(key) is int for key in decoded) decoded = stats.decode_flags(return_bit_values=True) assert decoded == {1: [APERTURE_FLAGS.MASKED_PIXELS], 2: [APERTURE_FLAGS.PARTIAL_OVERLAP]} # Scalar ApertureStats also returns a dictionary stats = ApertureStats(data, CircularAperture((0.0, 12.0), r=3.0)) assert stats.decode_flags() == {1: ['partial_overlap']} def test_decode_flags_no_column_keyword(self, unit_data): """ Test that decode_flags no longer accepts a column keyword. """ data = unit_data aper = CircularAperture((12.0, 12.0), r=3.0) stats = ApertureStats(data, aper) match = 'unexpected keyword' with pytest.raises(TypeError, match=match): stats.decode_flags(column='flags') assert stats.decode_flags() == {1: []} def test_flags_docstring(self): """ Test that the flags docstring placeholder was substituted. """ docstring = ApertureStats.flags.__doc__ assert '' not in docstring assert "**1** (``'no_overlap'``)" in docstring class TestSingularCovariance: """ Tests for the singular_covariance flag, which is always evaluated by the flags property. """ @pytest.mark.usefixtures('maybe_mask_path') def test_set_without_shape_access(self): """ Test that the singular_covariance bit is set without any prior covariance-derived property access. """ data = _single_pixel_data() aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) assert (stats.flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 assert 'singular_covariance' in stats.decode_flags()[1] @pytest.mark.usefixtures('maybe_mask_path') def test_array_and_guards(self): """ Test that the singular_covariance bit is set for an array of sources and that the covariance computation is guarded against sources with no overlap (undefined moments). """ data = _single_pixel_data() data[6, 6] = 100.0 yy, xx = np.mgrid[0:25, 0:25] data = data + 50.0 * np.exp(-((xx - 18)**2 + (yy - 18)**2) / (2 * 2.5**2)) aper = CircularAperture([(6.0, 6.0), (18.0, 18.0), (-50.0, 12.0)], r=4.0) stats = ApertureStats(data, aper) flags = stats.flags covar_flag = APERTURE_FLAGS.SINGULAR_COVARIANCE assert (flags[0] & covar_flag) != 0 # singular point source assert (flags[1] & covar_flag) == 0 # extended source assert (flags[2] & covar_flag) == 0 # no-overlap: not flagged singular assert (flags[2] & APERTURE_FLAGS.NO_OVERLAP) != 0 @pytest.mark.usefixtures('maybe_mask_path') def test_extended_source_not_flagged(self): """ Test that a well-resolved source with a nonsingular covariance matrix is not flagged as singular. """ yy, xx = np.mgrid[0:25, 0:25] data = 100.0 * np.exp(-((xx - 12)**2 + (yy - 12)**2) / (2 * 3.0**2)) aper = CircularAperture((12.0, 12.0), r=6.0) stats = ApertureStats(data, aper) assert (stats.flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) == 0 @pytest.mark.usefixtures('maybe_mask_path') def test_in_default_table(self): """ Test that the singular_covariance bit is reflected in the default to_table() output. """ data = _single_pixel_data() aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) tbl = stats.to_table() assert (tbl['flags'][0] & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 @pytest.mark.usefixtures('maybe_mask_path') def test_to_table_flags_only(self): """ Test that requesting only the 'flags' column reports the singular_covariance bit. """ data = _single_pixel_data() aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) tbl = stats.to_table(columns=['flags']) assert (tbl['flags'][0] & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 @pytest.mark.usefixtures('maybe_mask_path') def test_in_properties(self): """ Test that the singular_covariance bit is reflected in the properties list even if no shape properties are accessed. """ data = _single_pixel_data() aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) assert 'flags' in stats.properties @pytest.mark.usefixtures('maybe_mask_path') def test_slicing(self): """ Test that the singular_covariance bit is preserved when slicing an array of ApertureStats objects. """ data = _single_pixel_data() data[6, 6] = 100.0 aper = CircularAperture([(6.0, 6.0), (12.0, 12.0)], r=4.0) stats = ApertureStats(data, aper) covar_flag = APERTURE_FLAGS.SINGULAR_COVARIANCE assert (stats[0].flags & covar_flag) != 0 assert (stats[1].flags & covar_flag) != 0 def test_rank1_degeneracy(self): """ Test that a rank-1 degenerate source is flagged as singular. A rank-1 degenerate source has one unresolved axis and one extended axis, so its covariance matrix has one tiny eigenvalue. The singularity criterion is that the minor-axis variance is below a floor of ``1/12`` (the variance of a uniform distribution over a unit pixel). This is a more robust criterion than the determinant test, which can be fooled by a rank-1 source with a large major-axis variance. """ # Minor-axis variance 0.05 < 1/12, major-axis variance 0.5 stats = _stats_with_injected_covariance(cov_xx=0.5, cov_yy=0.05, cov_xy=0.0) delta = 1.0 / 12 assert delta**2 < 0.5 * 0.05 # determinant test alone would miss it assert delta > 0.05 # minor-axis variance below the floor assert stats._singular_covariance_mask[0] assert (stats.flags[0] & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 def test_single_pixel_regularization(self): """ Test that a single-pixel source has a variance of ``1/12`` along both axes. """ aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(_single_pixel_data(), aper) assert (stats.flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 assert_array_equal(stats.covariance.value, np.eye(2) / 12) assert stats.orientation.value == 0.0 def test_tilted_line_regularization(self): """ Test a tilted line that is one pixel wide. Its covariance matrix has rank 1, so its determinant is zero apart from rounding, which can make it slightly negative. The shape must be regularized and not set to NaN. """ data = np.zeros(UNIT_SHAPE) for i in range(7): data[9 + i, 9 + i] = 1.0 + i aper = CircularAperture((12.0, 12.0), r=6.0) stats = ApertureStats(data, aper) assert (stats.flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 assert_allclose(stats.semiminor_axis.value, np.sqrt(1.0 / 12)) assert stats.semimajor_axis.value > 1.0 assert_allclose(stats.orientation.value, 45.0) def test_isotropic_regularization(self): """ Test that a tilted source that is unresolved along both axes becomes exactly isotropic with a zero orientation. """ data = np.zeros(UNIT_SHAPE) data[12, 12] = 97.0 data[13, 13] = 3.0 aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) assert (stats.flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 assert_array_equal(stats.covariance.value, np.eye(2) / 12) assert stats.orientation.value == 0.0 def test_rank1_regularization(self): """ Test that only the unresolved axis of a rank-1 degenerate source is raised to the ``1/12`` floor. """ stats = _stats_with_injected_covariance(cov_xx=0.5, cov_yy=0.05, cov_xy=0.0) assert_allclose(stats.covariance_eigvals.value[0], [0.5, 1.0 / 12]) assert_allclose(stats.semimajor_axis.value[0], np.sqrt(0.5)) assert_allclose(stats.semiminor_axis.value[0], np.sqrt(1.0 / 12)) def test_not_positive_semidefinite(self): """ Test that a covariance matrix that is not positive semidefinite is flagged as an undefined shape, not as singular, and gives NaN shape properties. """ stats = _stats_with_injected_covariance(cov_xx=1.0, cov_yy=1.0, cov_xy=2.0) assert (1.0 * 1.0 - 2.0**2) < 0 # negative determinant assert not stats._singular_covariance_mask[0] assert (stats.flags[0] & APERTURE_FLAGS.SINGULAR_COVARIANCE) == 0 assert (stats.flags[0] & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 assert np.all(np.isnan(stats.covariance)) @pytest.mark.usefixtures('maybe_mask_path') def test_not_positive_semidefinite_from_data(self): """ Test a source with a positive net flux whose negative pixel values make the x variance negative. """ data = np.zeros(UNIT_SHAPE) data[12, 12] = 100.0 data[12, 8] = -20.0 data[12, 16] = -20.0 aper = CircularAperture((12.0, 12.0), r=6.0) stats = ApertureStats(data, aper) assert stats.moments[0, 0] > 0 assert stats._raw_covariance[0, 0, 0] < 0 assert stats.flags == APERTURE_FLAGS.UNDEFINED_SHAPE assert np.isnan(stats.fwhm) assert_allclose(stats.centroid, (12.0, 12.0)) class TestCentroidOutsideBbox: """ Tests for the centroid_outside_bbox flag, which marks sources whose centroid lies outside the aperture bounding box. """ @staticmethod def _make_data(): """ Make data with a small positive net flux from two pixels of opposite sign, which puts the centroid far from both. """ data = np.zeros(UNIT_SHAPE) data[12, 16] = 10.0 data[12, 8] = -9.9 return data @pytest.mark.usefixtures('maybe_mask_path') def test_outside(self): """ Test that a centroid far outside the aperture is flagged. """ aper = CircularAperture((12.0, 12.0), r=6.0) stats = ApertureStats(self._make_data(), aper) assert stats.moments[0, 0] > 0 assert stats.centroid[0] > 100.0 assert (stats.flags & APERTURE_FLAGS.CENTROID_OUTSIDE_BBOX) != 0 assert 'centroid_outside_bbox' in stats.decode_flags()[1] @pytest.mark.usefixtures('maybe_mask_path') def test_array_and_guards(self): """ Test an array of sources. A source with a centroid inside the aperture and sources with a NaN centroid (zero flux or no overlap) are not flagged. """ data = self._make_data() data[4, 20] = 50.0 # positive-flux source aper = CircularAperture([(12.0, 12.0), (20.0, 4.0), (3.0, 21.0), (-50.0, 12.0)], r=2.0) aper0 = CircularAperture((12.0, 12.0), r=6.0) assert (ApertureStats(data, aper0).flags & APERTURE_FLAGS.CENTROID_OUTSIDE_BBOX) != 0 stats = ApertureStats(data, aper) outside = (stats.flags & APERTURE_FLAGS.CENTROID_OUTSIDE_BBOX) != 0 assert_array_equal(outside, [False, False, False, False]) assert np.all(np.isnan(stats.centroid[[0, 2, 3]])) assert_allclose(stats.centroid[1], (20.0, 4.0)) def test_bounding_box_edge(self): """ Test that the bounding box includes the full extent of its edge pixels. """ data = np.zeros(UNIT_SHAPE) aper = CircularAperture([(12.0, 12.0)], r=3.0) stats = ApertureStats(data, aper) xmax = stats.bbox_xmax[0] for xcen, expected in ((xmax + 0.49, False), (xmax + 0.51, True)): stats = ApertureStats(data, aper) stats.centroid = np.array([[xcen, 12.0]]) outside = (stats.flags[0] & APERTURE_FLAGS.CENTROID_OUTSIDE_BBOX) != 0 assert outside == expected def test_annulus_hole(self): """ Test that a centroid in the hole of an annulus is not flagged. """ data = np.ones(UNIT_SHAPE) aper = CircularAnnulus((12.0, 12.0), r_in=4.0, r_out=8.0) stats = ApertureStats(data, aper) assert_allclose(stats.centroid, (12.0, 12.0)) assert (stats.flags & APERTURE_FLAGS.CENTROID_OUTSIDE_BBOX) == 0 class TestUndefinedShape: """ Tests for the undefined_shape flag, which marks sources whose net flux is not positive and is always evaluated by the flags property. """ @pytest.mark.usefixtures('maybe_mask_path') def test_set_without_moment_access(self): """ Test that the undefined_shape bit is set without any prior moment-derived property access. """ data = np.zeros(UNIT_SHAPE) aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) assert (stats.flags & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 assert 'undefined_shape' in stats.decode_flags()[1] assert np.all(np.isnan(stats.centroid)) @pytest.mark.usefixtures('maybe_mask_path') @pytest.mark.parametrize('value', [0.0, -1.0]) def test_non_positive_flux(self, value): """ Test that both zero and negative net flux set the undefined_shape bit. """ data = np.full(UNIT_SHAPE, value) aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) assert (stats.flags & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 @pytest.mark.usefixtures('maybe_mask_path') def test_positive_flux_not_flagged(self): """ Test that a source with positive net flux is not flagged. """ data = np.ones(UNIT_SHAPE) aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) assert (stats.flags & APERTURE_FLAGS.UNDEFINED_SHAPE) == 0 @pytest.mark.usefixtures('maybe_mask_path') def test_array_and_guards(self): """ Test the undefined_shape bit for an array of sources. A fully masked source is flagged, and a source with no overlap is not (it is reported by the overlap bits). """ data = np.zeros(UNIT_SHAPE) data[16:21, 16:21] = 50.0 # positive-flux source at (18, 18) mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[0:12, 0:12] = True # fully mask the third aperture aper = CircularAperture([(6.0, 18.0), (18.0, 18.0), (6.0, 6.0), (-50.0, 12.0)], r=4.0) stats = ApertureStats(data, aper, mask=mask) flags = stats.flags shape_flag = APERTURE_FLAGS.UNDEFINED_SHAPE assert (flags[0] & shape_flag) != 0 # zero-flux source assert (flags[1] & shape_flag) == 0 # positive-flux source assert (flags[2] & shape_flag) != 0 # fully masked assert (flags[2] & APERTURE_FLAGS.ALL_MASKED) != 0 assert (flags[3] & shape_flag) == 0 # no overlap: not flagged assert (flags[3] & APERTURE_FLAGS.NO_OVERLAP) != 0 @pytest.mark.usefixtures('maybe_mask_path') def test_empty_source_central_moments(self): """ Test the central moments of sources with no valid pixels. The zeroth central moment does not depend on the centroid, so it equals the zeroth raw moment (zero for a fully masked source and NaN for a source with no overlap). The other central moments are NaN because the centroid is undefined. """ data = np.ones(UNIT_SHAPE) mask = np.zeros(UNIT_SHAPE, dtype=bool) mask[0:12, 0:12] = True # fully mask the first aperture aper = CircularAperture([(6.0, 6.0), (-50.0, 12.0), (18.0, 18.0)], r=4.0) stats = ApertureStats(data, aper, mask=mask) raw = stats.moments central = stats.moments_central assert_array_equal(central[:, 0, 0], raw[:, 0, 0]) assert central[0, 0, 0] == 0.0 assert np.isnan(central[1, 0, 0]) assert central[2, 0, 0] > 0 for i in (0, 1): assert np.all(np.isnan(central[i].ravel()[1:])) assert np.all(np.isfinite(central[2])) @pytest.mark.usefixtures('maybe_mask_path') def test_in_default_table(self): """ Test that the undefined_shape bit is reflected in the default to_table() output, which evaluates the moment-derived columns. """ data = np.zeros(UNIT_SHAPE) aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) tbl = stats.to_table() assert (tbl['flags'][0] & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 @pytest.mark.usefixtures('maybe_mask_path') def test_to_table_flags_only(self): """ Test that requesting only the 'flags' column reports the undefined_shape bit. """ data = np.zeros(UNIT_SHAPE) aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) tbl = stats.to_table(columns=['flags']) assert (tbl['flags'][0] & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 @pytest.mark.usefixtures('maybe_mask_path') def test_slicing(self): """ Test that the undefined_shape bit is preserved when slicing an ApertureStats object. """ data = np.zeros(UNIT_SHAPE) data[18, 18] = 100.0 aper = CircularAperture([(6.0, 6.0), (18.0, 18.0)], r=4.0) stats = ApertureStats(data, aper) shape_flag = APERTURE_FLAGS.UNDEFINED_SHAPE assert (stats[0].flags & shape_flag) != 0 assert (stats[1].flags & shape_flag) == 0 @pytest.mark.usefixtures('maybe_mask_path') def test_with_singular_covariance(self): """ Test that a single negative pixel sets both the undefined_shape and singular_covariance bits once the covariance is computed. """ data = np.zeros(UNIT_SHAPE) data[12, 12] = -100.0 aper = CircularAperture((12.0, 12.0), r=5.0) stats = ApertureStats(data, aper) flags = stats.flags assert (flags & APERTURE_FLAGS.UNDEFINED_SHAPE) != 0 assert (flags & APERTURE_FLAGS.SINGULAR_COVARIANCE) != 0 astropy-photutils-24be529/photutils/background/000077500000000000000000000000001526452531300217625ustar00rootroot00000000000000astropy-photutils-24be529/photutils/background/__init__.py000066400000000000000000000005411526452531300240730ustar00rootroot00000000000000# 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-24be529/photutils/background/background_2d.py000066400000000000000000001554621526452531300250550ustar00rootroot00000000000000# 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 from concurrent.futures import ThreadPoolExecutor from functools import cached_property from typing import NamedTuple import astropy.units as u import numpy as np from astropy.nddata import NDData, block_replicate, reshape_as_blocks from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import generic_filter from photutils.aperture import RectangularAperture from photutils.aperture._batch_stats import batch_sigma_clip_stats from photutils.background.core import (SIGMA_CLIP, BiweightLocationBackground, BiweightScaleBackgroundRMS, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, 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'] class _BoxStatsSpec(NamedTuple): """ Parameters for the fused sigma-clipped box-statistics kernel. See ``Background2D._make_box_stats_spec``. """ sigma_lower: float sigma_upper: float maxiters: int cenfunc_code: int stdfunc_code: int do_clip: bool bkg_kind: str median_factor: float mean_factor: float biloc_c: float biloc_m: float rms_kind: str biscale_c: float biscale_m: float 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 : 2D `~numpy.ndarray`, `~numpy.ma.MaskedArray`, \ `~astropy.units.Quantity`, or `~astropy.nddata.NDData` The 2D array from which to estimate the background and/or background RMS map. If ``data`` is a `~numpy.ma.MaskedArray`, its mask is combined with the input ``mask``. 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``, nonfinite ``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 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 : float or `None`, optional The threshold value used for selective median filtering of the low-resolution 2D background and background RMS maps. The median filter will be applied to only the boxes with background 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. The default is a `~astropy.stats.SigmaClip` instance with ``sigma=3.0`` and ``maxiters=10``. bkg_estimator : callable, optional A callable object (a function or e.g., an instance of any `~photutils.background.BackgroundBase` subclass) used to estimate the background in each of the boxes. The callable object must take in a `~numpy.ndarray`, in which masked and sigma-clipped pixels are set to NaN, and have an ``axis`` keyword. Internally, the background is calculated along the last axis (``axis=-1``) of a 3D array, and the callable object must return an array with that axis removed, ignoring the NaN values. The callable object must also accept ``axis=None`` and return a scalar in that case. 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 `~numpy.ndarray`, in which masked and sigma-clipped pixels are set to NaN, and have an ``axis`` keyword. Internally, the background RMS is calculated along the last axis (``axis=-1``) of a 3D array, and the callable object must return an array with that axis removed, ignoring the NaN values. The callable object must also accept ``axis=None`` and return a scalar in that case. 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`. n_threads : int, optional The number of threads to use to compute the box statistics and to resize the low-resolution meshes to the full-size maps returned by the ``background`` and ``background_rms`` properties. The default is 1 (no multithreading). When ``n_threads`` > 1, the work is divided into chunks along the y-axis and processed concurrently. The box statistics are identical to the single-threaded computation and the resized maps are identical up to floating-point rounding. The underlying kernels release the Python global interpreter lock (GIL), so multithreading can significantly speed up the background estimation for large images. Resizing the meshes concurrently requires scipy 1.16 or later. With older versions the resizing step is single-threaded. If a custom ``bkg_estimator`` or ``bkg_rms_estimator`` callable is input, it must be thread-safe. 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 version 4.0. 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. ``np.float32`` and ``np.float64`` input data produce background and background RMS outputs with the same dtype as the input data. Other floating-point dtypes (e.g., ``np.float16``) are not supported. When the ``sigma_clip`` input is `None` or uses the string ``cenfunc`` and ``stdfunc`` options, and the ``bkg_estimator`` and ``bkg_rms_estimator`` inputs are standard photutils estimator classes, the sigma clipping and box statistics are computed together in compiled code. Otherwise, a generic path that calls ``sigma_clip`` and the estimators is used. Both paths produce the same results to within floating-point rounding. 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, n_threads=1, interpolator=None): if isinstance(data, u.Quantity): self._unit = data.unit data = data.value elif isinstance(data, NDData): # includes CCDData self._unit = data.unit data = data.data else: self._unit = None # For masked-array input, extract the data values. The data mask # is combined with the input mask below. data_mask = None if isinstance(data, np.ma.MaskedArray): if data.mask is not np.ma.nomask: data_mask = np.asarray(data.mask, dtype=bool) data = data.data # 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 nonfinite_mask = ~np.isfinite(self._data) if np.all(nonfinite_mask): msg = ('Input data contains all nonfinite (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') if data_mask is not None: if self._mask is None: self._mask = data_mask else: self._mask = np.logical_or(self._mask, data_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=self._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 (isinstance(n_threads, bool) or not isinstance(n_threads, (int, np.integer)) or n_threads < 1): msg = 'n_threads must be a positive integer' raise ValueError(msg) self.n_threads = int(n_threads) 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_stats_spec = self._make_box_stats_spec() self._box_n_pixels = 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, 'n_threads': self.n_threads} # Perform the initial calculations to avoid storing large data # arrays and to keep the memory usage minimal bkg_stats, bkgrms_stats, self._n_good = self._calculate_stats( nonfinite_mask) # This is used to selectively filter the low-resolution maps self._min_bkg_stats = nanmin(bkg_stats) # Store a mask of the excluded mesh values (NaNs) in the # low-resolution maps self._mesh_nan_mask = np.isnan(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 # Compute the low-resolution background and background RMS # meshes. The mesh arrays are small, so this is inexpensive # compared to the box statistics above, and computing them # here keeps the instance immutable after construction. The # unfiltered background mesh defines the pixels selected by # filter_threshold for both meshes. bkg_mesh = self._interpolate_grid(bkg_stats) bkgrms_mesh = self._interpolate_grid(bkgrms_stats) bkg_mesh_filt = self._filter_grid(bkg_mesh, bkg_mesh) bkgrms_mesh_filt = self._filter_grid(bkgrms_mesh, bkg_mesh) self._background_mesh = self._apply_units(bkg_mesh_filt) self._background_rms_mesh = self._apply_units(bkgrms_mesh_filt) 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', 'n_threads', 'interpolator') data_repr = f'' mask_repr = None if not self._has_mask else data_repr coverage_mask_repr = (None if self.coverage_mask is None else 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 (name in ('mask', 'coverage_mask') and array.dtype != bool): msg = f'{name} must be a boolean array' raise TypeError(msg) 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` or `~astropy.units.Quantity` 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 nonfinite (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 @cached_property def _good_n_pixels_threshold(self): """ The minimum number of unmasked pixels required for a box to be included in the low-resolution map. A box is excluded if it contains fewer unmasked pixels than this threshold. For exclude_percentile=0, only boxes with zero masked pixels 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_n_pixels def _make_box_stats_spec(self): """ The fused box-statistics kernel parameters, or `None`. Returns a `_BoxStatsSpec` when the ``sigma_clip``, ``bkg_estimator``, and ``bkg_rms_estimator`` inputs are all supported by the fused sigma-clipping and statistics kernel, and `None` otherwise (in which case the generic path based on `astropy.stats.SigmaClip` and the estimator callables is used). The fast path supports the string ``cenfunc`` values 'median'/'mean'/'biweight', the string ``stdfunc`` values 'std'/'mad_std'/'biweight', no spatial growing, and the standard photutils estimator classes. For the biweight-based estimators, the ``M`` location anchor must be `None` or a finite scalar. """ sigma_clip = self.sigma_clip if sigma_clip is None: do_clip = False sigma_lower = sigma_upper = 0.0 maxiters = 0 cenfunc_code = stdfunc_code = 0 else: # Spatial growing (``grow``) is not supported if (not isinstance(sigma_clip, SigmaClip) or not (isinstance(sigma_clip.cenfunc, str) and sigma_clip.cenfunc in ('median', 'mean', 'biweight')) or not (isinstance(sigma_clip.stdfunc, str) and sigma_clip.stdfunc in ('std', 'mad_std', 'biweight')) or sigma_clip.grow): return None do_clip = True sigma_lower = float(sigma_clip.sigma_lower) sigma_upper = float(sigma_clip.sigma_upper) maxiters = sigma_clip.maxiters if maxiters is None or np.isinf(maxiters): maxiters = -1 else: maxiters = int(maxiters) cenfunc_code = {'median': 0, 'mean': 1, 'biweight': 2}[sigma_clip.cenfunc] stdfunc_code = {'std': 0, 'mad_std': 1, 'biweight': 2}[sigma_clip.stdfunc] bkg_estimator = self.bkg_estimator median_factor = mean_factor = 0.0 biloc_c = biloc_m = np.nan if type(bkg_estimator) is MeanBackground: bkg_kind = 'mean' elif type(bkg_estimator) is MedianBackground: bkg_kind = 'median' elif type(bkg_estimator) in (ModeEstimatorBackground, MMMBackground): bkg_kind = 'mode' median_factor = float(bkg_estimator.median_factor) mean_factor = float(bkg_estimator.mean_factor) elif type(bkg_estimator) is SExtractorBackground: bkg_kind = 'sextractor' elif type(bkg_estimator) is BiweightLocationBackground: m_value = bkg_estimator.M if m_value is not None and (np.ndim(m_value) != 0 or not np.isfinite(m_value)): return None bkg_kind = 'biweight_location' biloc_c = float(bkg_estimator.c) biloc_m = np.nan if m_value is None else float(m_value) else: return None rms_estimator = self.bkg_rms_estimator biscale_c = biscale_m = np.nan if type(rms_estimator) is StdBackgroundRMS: rms_kind = 'std' elif type(rms_estimator) is MADStdBackgroundRMS: rms_kind = 'mad_std' elif type(rms_estimator) is BiweightScaleBackgroundRMS: m_value = rms_estimator.M if m_value is not None and (np.ndim(m_value) != 0 or not np.isfinite(m_value)): return None rms_kind = 'biweight_scale' biscale_c = float(rms_estimator.c) biscale_m = np.nan if m_value is None else float(m_value) else: return None return _BoxStatsSpec(sigma_lower=sigma_lower, sigma_upper=sigma_upper, maxiters=maxiters, cenfunc_code=cenfunc_code, stdfunc_code=stdfunc_code, do_clip=do_clip, bkg_kind=bkg_kind, median_factor=median_factor, mean_factor=mean_factor, biloc_c=biloc_c, biloc_m=biloc_m, rms_kind=rms_kind, biscale_c=biscale_c, biscale_m=biscale_m) def _fast_box_statistics(self, data, *, axis=None): """ Compute the background, background RMS, and surviving pixel count in each box using the fused sigma-clipping and statistics kernel. The kernel operates on the finite values of each box in a single pass, without generating a sigma-clipped copy of the box data, and produces the same results, to within floating-point rounding, as sequentially applying `astropy.stats.SigmaClip` and the estimator classes. The kernel releases the GIL, so this method can run in parallel threads. Parameters ---------- data : `~numpy.ndarray` The array of box data, where NaN values mark masked pixels. axis : int or `None`, optional The axis along which to compute the statistics. Must be the last axis (``-1``). If `None`, the entire array is treated as a single box and scalar values are returned. Returns ------- bkg : `~numpy.ndarray` or float The background statistics in each box. bkgrms : `~numpy.ndarray` or float The background RMS statistics in each box. n_good : `~numpy.ndarray` or int The number of surviving (unmasked and unclipped) pixels in each box. """ spec = self._box_stats_spec if axis is None: data2d = data.reshape(1, -1) else: data2d = data.reshape(-1, data.shape[-1]) # Sort each box in its own dtype (NaN values are placed last) # and then cast to the float64 rows the kernel requires. The # cast is exact and preserves the order, and sorting float32 # data is about twice as fast as sorting a float64 copy. data2d = np.sort(data2d, axis=-1).astype(np.float64, copy=False) (mean, median, std, madstd, biloc, biscale, n_good) = batch_sigma_clip_stats( data2d, spec.sigma_lower, spec.sigma_upper, spec.maxiters, spec.cenfunc_code, spec.stdfunc_code, spec.do_clip, spec.rms_kind == 'mad_std', spec.bkg_kind == 'biweight_location', spec.biloc_c, spec.biloc_m, spec.rms_kind == 'biweight_scale', spec.biscale_c, spec.biscale_m) if spec.bkg_kind == 'mean': bkg = mean elif spec.bkg_kind == 'median': bkg = median elif spec.bkg_kind == 'mode': bkg = ((spec.median_factor * median) - (spec.mean_factor * mean)) elif spec.bkg_kind == 'biweight_location': bkg = biloc else: # 'sextractor' bkg = (2.5 * median) - (1.5 * mean) # Set the background to the mean where the std is zero mean_mask = std == 0 bkg[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. with np.errstate(divide='ignore', invalid='ignore'): med_mask = (np.abs(mean - median) / std) >= 0.3 mask = np.logical_and(med_mask, np.logical_not(mean_mask)) bkg[mask] = median[mask] if spec.rms_kind == 'std': bkgrms = std elif spec.rms_kind == 'mad_std': bkgrms = madstd else: # 'biweight_scale' bkgrms = biscale # Preserve the (float) dtype of the input box data if data.dtype != np.float64: bkg = bkg.astype(data.dtype) bkgrms = bkgrms.astype(data.dtype) if axis is None: return bkg[0], bkgrms[0], n_good[0] out_shape = data.shape[:-1] return (bkg.reshape(out_shape), bkgrms.reshape(out_shape), n_good.reshape(out_shape)) def _sigmaclip_boxes(self, data, *, axis, sigma_clip=None): """ 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 3D array where the first two dimensions represent the y and x positions of the boxes and the last dimension contains the flattened pixels of 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 array of box data. axis : int, tuple of int, or `None` The axis or axes along which to sigma clip the data. If `None`, the entire array is clipped as a single box. sigma_clip : `~astropy.stats.SigmaClip` or `None`, optional The sigma-clipping instance to apply. If `None`, no sigma clipping is performed. `~astropy.stats.SigmaClip` stores internal state on the instance during calls, so each thread must use its own instance. Returns ------- data : `~numpy.ndarray` The sigma-clipped data. """ if sigma_clip is not None: data = sigma_clip(data, axis=axis, masked=False, copy=False) return data def _compute_box_statistics(self, data, *, sigma_clip=None, axis=None): """ Compute the background and background RMS statistics in each box. Parameters ---------- data : `~numpy.ndarray` The array of box data, where NaN values mark masked pixels. sigma_clip : `~astropy.stats.SigmaClip` or `None`, optional The sigma-clipping instance to apply to the box data. If `None`, no sigma clipping is performed. axis : int, tuple of int, or `None`, optional The axis or axes along which to compute the statistics. If `None`, the entire array is treated as a single box and scalar values are returned. 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. n_good : 2D `~numpy.ndarray` or int The number of surviving (unmasked and unclipped) pixels in each box. """ if self._box_stats_spec is not None: bkg, bkgrms, n_good = self._fast_box_statistics(data, axis=axis) else: data = self._sigmaclip_boxes(data, axis=axis, sigma_clip=sigma_clip) # Make 2D arrays of the box statistics bkg = self.bkg_estimator(data, axis=axis) bkgrms = self.bkg_rms_estimator(data, axis=axis) n_good = np.count_nonzero(~np.isnan(data), axis=axis) # Mask boxes with too few unmasked pixels. Completely masked # boxes are always excluded. box_mask = ((n_good < self._good_n_pixels_threshold) | (n_good == 0)) if np.ndim(bkg) == 0: if box_mask: # single corner box # np.nan is float64, so 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, n_good def _parallel_box_statistics(self, data): """ Compute the statistics for the core boxes, optionally using multiple threads. When ``n_threads`` > 1, the boxes are divided into chunks along the first (box row) axis and processed concurrently. The box statistics are independent between boxes, so the results are identical to the single-threaded computation. The underlying sigma-clipping and statistics kernels release the GIL, allowing the threads to run in parallel. Parameters ---------- data : 3D `~numpy.ndarray` The array of box data, where the first two axes are the box grid positions and the last axis contains the (flattened) pixels within each box. Returns ------- bkg : 2D `~numpy.ndarray` The background statistics in each box. bkgrms : 2D `~numpy.ndarray` The background RMS statistics in each box. n_good : 2D `~numpy.ndarray` The number of unmasked and unclipped pixels in each box. """ n_chunks = min(self.n_threads, data.shape[0]) if n_chunks == 1: return self._compute_box_statistics( data, axis=-1, sigma_clip=self.sigma_clip) def worker(chunk): # SigmaClip stores internal state on the instance during # calls, so each thread needs its own copy sigma_clip = (None if self.sigma_clip is None else copy.copy(self.sigma_clip)) return self._compute_box_statistics(chunk, axis=-1, sigma_clip=sigma_clip) chunks = np.array_split(data, n_chunks, axis=0) with ThreadPoolExecutor(max_workers=n_chunks) as executor: results = list(executor.map(worker, chunks)) bkg = np.vstack([result[0] for result in results]) bkgrms = np.vstack([result[1] for result in results]) n_good = np.vstack([result[2] for result in results]) return bkg, bkgrms, n_good def _calculate_stats(self, nonfinite_mask): """ Calculate the background and background RMS statistics in each box. Parameters ---------- nonfinite_mask : 2D bool `~numpy.ndarray` A mask of the nonfinite (NaN or infinity) values in the input data. Returns ------- bkg : 2D `~numpy.ndarray` The background statistics in each box. bkgrms : 2D `~numpy.ndarray` The background RMS statistics in each box. n_good : 2D `~numpy.ndarray` The number of unmasked and unclipped 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 nonfinite values that aren't already # masked and combine all masks mask = self._combine_all_masks(nonfinite_mask) self._box_n_pixels = np.prod(self.box_size) n_boxes = self._data.shape // self.box_size y1, x1 = n_boxes * self.box_size # Suppress the AstropyUserWarning issued by sigma clipping # for the NaN values inserted for masked pixels. The warning # filter is set once here, in the main thread, because # warnings.catch_warnings manipulates global state and is not # thread-safe. with warnings.catch_warnings(): warnings.simplefilter('ignore', category=AstropyUserWarning) # 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((*n_boxes, -1)) core_mask = core_mask.reshape((*n_boxes, -1)) core[core_mask] = np.nan bkg, bkgrms, n_good = self._parallel_box_statistics(core) extra_row = y1 < self._data.shape[0] extra_col = x1 < self._data.shape[1] 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_stats = self._compute_box_statistics( row_data, axis=-1, sigma_clip=self.sigma_clip) row_bkg, row_bkgrms, row_n_good = row_stats 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_stats = self._compute_box_statistics( col_data, axis=-1, sigma_clip=self.sigma_clip) col_bkg, col_bkgrms, col_n_good = col_stats if extra_row and extra_col: # Extra corner box, appended to the 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_stats = self._compute_box_statistics( corner_data, axis=None, sigma_clip=self.sigma_clip) crn_bkg, crn_bkgrms, crn_n_good = crn_stats col_bkg = np.vstack((col_bkg, crn_bkg)) col_bkgrms = np.vstack((col_bkgrms, crn_bkgrms)) col_n_good = np.vstack((col_n_good, crn_n_good)) # 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]]) n_good = np.vstack([n_good, row_n_good[:, 0]]) if extra_col: bkg = np.hstack([bkg, col_bkg]) bkgrms = np.hstack([bkgrms, col_bkgrms]) n_good = np.hstack([n_good, col_n_good]) if np.all(np.isnan(bkg)): msg = ('All boxes contain fewer than ' f'{self._good_n_pixels_threshold} unmasked or finite ' 'pixels or are completely masked ' f'(box_size={tuple(self.box_size)}, ' f'exclude_percentile={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, n_good 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 array is returned unchanged. 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, bkg_grid): """ 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. bkg_grid : 2D `~numpy.ndarray` The NaN-interpolated, unfiltered background mesh used to select the pixels to filter. Returns ------- result : 2D `~numpy.ndarray` The filtered 2D array of mesh values. """ above_threshold = bkg_grid > 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 and 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, bkg_grid): """ Apply a 2D median filter to a low-resolution 2D image. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array of mesh values. bkg_grid : 2D `~numpy.ndarray` The NaN-interpolated, unfiltered background mesh used to select the pixels to filter when ``filter_threshold`` is set. 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, bkg_grid) 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] @property def background_mesh(self): """ The low-resolution background image. This image is equivalent to the low-resolution "MINIBACK" background map check image in SourceExtractor. """ return self._background_mesh @property 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. """ return self._background_rms_mesh @property @deprecated(since='3.0', alternative='n_pixels_mesh', until='4.0') def npixels_mesh(self): """ A 2D array of the number of pixels used to compute the statistics in each mesh. """ return self._n_good @property def n_pixels_mesh(self): """ A 2D array of the number of pixels used to compute the statistics in each mesh. """ return self._n_good @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. .. 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]] @cached_property 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 np.median(self.background_mesh) @cached_property 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 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` (or `~astropy.units.Quantity` if the input ``data`` has units) 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` (or `~astropy.units.Quantity` if the input ``data`` has units) 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. Only the boxes that were included in the low-resolution map are plotted. Boxes excluded by ``exclude_percentile`` or because they are completely masked are not shown. 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-24be529/photutils/background/core.py000066400000000000000000000653111526452531300232720ustar00rootroot00000000000000# 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. The default is a `~astropy.stats.SigmaClip`\n' ' instance with ``sigma=3.0`` and ``maxiters=10``.' ) 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 _use_fast_flat_clip(sigma_clip): """ Whether an ``axis=None`` sigma clip can be routed through astropy's fast C implementation. astropy dispatches ``axis=None`` clipping to its slower Python code path even when the ``cenfunc`` and ``stdfunc`` options are supported by its fast C implementation, which requires an axis. When this function returns `True`, `_prepare_data` instead clips the raveled data along ``axis=0`` to use the fast C path. Parameters ---------- sigma_clip : `~astropy.stats.SigmaClip` The sigma-clipping instance. Returns ------- result : bool Whether the fast C clipping path can be used. """ return (isinstance(sigma_clip.cenfunc, str) and sigma_clip.cenfunc in ('median', 'mean') and isinstance(sigma_clip.stdfunc, str) and sigma_clip.stdfunc in ('std', 'mad_std') and not sigma_clip.grow) 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 replaces masked 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 : `~numpy.ndarray` 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. The input is returned unchanged if it is not a masked array and sigma clipping is disabled. """ if sigma_clip is not None: if axis is None and _use_fast_flat_clip(sigma_clip): # Clip the raveled data along axis=0 to use astropy's # fast C implementation. Clipped values are replaced by # NaN (rather than removed), which is equivalent for the # NaN-aware statistics used by the estimator classes. return sigma_clip(np.ravel(data), axis=0, masked=False) 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 _prepare_biweight_location(location, data, axis): """ Prepare the ``M`` location input for the astropy biweight functions. The astropy biweight functions require ``M`` to be an array with the shape of the result (the shape of ``data`` with ``axis`` removed). A scalar ``M`` is therefore expanded to that shape. Parameters ---------- location : float, `~numpy.ndarray`, or `None` The input ``M`` value. data : `~numpy.ndarray` The data array. axis : int, tuple of int, or `None` The axis along which the biweight is calculated. Returns ------- location : `~numpy.ndarray` or `None` The ``M`` array, or `None` if the input is `None`. """ if location is None: return None location = np.asanyarray(location) if location.ndim == 0 and axis is not None: shape = tuple(np.delete(np.shape(data), axis)) location = np.full(shape, location) return location def _apply_masked(result, masked): """ Optionally return the result as a masked array with NaN values masked. 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 background values. Parameters ---------- 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. The default is a `~astropy.stats.SigmaClip` instance with ``sigma=3.0`` and ``maxiters=10``. """ @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 : `~numpy.ndarray` or `~numpy.ma.MaskedArray` The array for which to calculate the background value. axis : int, tuple of 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 NumPy floating-point scalar. The result is ``float32`` or ``float64``, depending on the dtype of the input data and on the sigma clipping. For example, sigma-clipped integer data give a ``float32`` result. """ class BackgroundRMSBase(_BackgroundCommonBase, abc.ABC): """ Base class for classes that estimate background RMS values. Parameters ---------- 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. The default is a `~astropy.stats.SigmaClip` instance with ``sigma=3.0`` and ``maxiters=10``. """ @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 : `~numpy.ndarray` or `~numpy.ma.MaskedArray` The array for which to calculate the background RMS value. axis : int, tuple of 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 NumPy floating-point scalar. The result is ``float32`` or ``float64``, depending on the dtype of the input data and on the sigma clipping. For example, sigma-clipped integer data give a ``float32`` result. """ @_insert_sigma_clip_doc class MeanBackground(BackgroundBase): """ Class to calculate the background in an array as the (sigma-clipped) mean. Parameters ---------- Examples -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import MeanBackground >>> data = np.arange(100.0) >>> 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) 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) 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 -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import MedianBackground >>> data = np.arange(100.0) >>> 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) 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) 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 -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import ModeEstimatorBackground >>> data = np.arange(100.0) >>> 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) 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) 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 -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import MMMBackground >>> data = np.arange(100.0) >>> 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) 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) 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 SourceExtractor algorithm. The background is calculated using a mode estimator of the form ``(2.5 * median) - (1.5 * mean)``. If ``abs(mean - median) / std >= 0.3`` then the median is used instead. If the standard deviation is zero then the mean is used. Parameters ---------- Examples -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import SExtractorBackground >>> data = np.arange(100.0) >>> 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) 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) 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 = nanmedian(data, axis=axis) # A scalar median means the reduction consumed all axes is_scalar = np.ndim(_median) == 0 _median = np.atleast_1d(_median) _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 the reduction consumed all axes, return a scalar # float, consistent with the other estimator classes if is_scalar: 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 or `None`, optional The location estimate about which the biweight is computed. If `None` (default), the median of the input data is used. Examples -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import BiweightLocationBackground >>> data = np.arange(100.0) >>> 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) 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) 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) m_value = _prepare_biweight_location(self.M, 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=m_value, 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 -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import StdBackgroundRMS >>> data = np.arange(100.0) >>> 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) 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) 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 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 -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import MADStdBackgroundRMS >>> data = np.arange(100.0) >>> 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) 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) 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 or `None`, optional The location estimate about which the biweight is computed. If `None` (default), the median of the input data is used. Examples -------- >>> import numpy as np >>> from astropy.stats import SigmaClip >>> from photutils.background import BiweightScaleBackgroundRMS >>> data = np.arange(100.0) >>> 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) 30.094338485893392 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) 30.094338485893392 """ @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) m_value = _prepare_biweight_location(self.M, 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=m_value, axis=axis, ignore_nan=True) return _apply_masked(result, masked) astropy-photutils-24be529/photutils/background/interpolators.py000066400000000000000000000332641526452531300252510ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for upsampling images for Background2D using interpolation. """ from concurrent.futures import ThreadPoolExecutor import numpy as np from astropy.units import Quantity from astropy.utils import minversion from astropy.utils.decorators import deprecated from scipy.ndimage import affine_transform, spline_filter, zoom from photutils.utils import ShepardIDWInterpolator from photutils.utils._repr import make_repr from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = ['BkgIDWInterpolator', 'BkgZoomInterpolator'] # scipy < 1.16 emits a UserWarning on the 1-D matrix code path of # affine_transform used by the threaded zoom. Suppressing the warning # with warnings.catch_warnings is not thread-safe, so the threaded # zoom requires scipy 1.16 or later. SCIPY_GE_1_16 = minversion('scipy', '1.16') class _BkgZoomInterpolator: """ Class to generate 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', 'mirror', 'constant', 'nearest', 'wrap'}, optional Points outside the boundaries of the input are filled according to the given mode. Default is 'reflect'. See `scipy.ndimage.zoom` for all of the allowed values. 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). The outer pixel edges of the mesh and of the output image are therefore aligned, instead of the centers of their corner pixels. The output is computed concurrently over bands of rows when all of the following hold: the call includes an ``n_threads`` keyword larger than 1 (e.g., from `~photutils.background.Background2D`), ``mode`` is 'reflect' or 'mirror', and scipy 1.16 or later is installed. The multithreaded result is identical to the single `~scipy.ndimage.zoom` call up to floating-point rounding. """ 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 _threaded_zoom(self, data, zoom_factor, n_threads): """ Compute `~scipy.ndimage.zoom` (``grid_mode=True``) of a 2D array using multiple threads over bands of output rows. The spline prefilter is applied once to the (small) input mesh exactly as `~scipy.ndimage.zoom` does. The output resampling, which dominates the cost, is then evaluated concurrently over row bands with `~scipy.ndimage.affine_transform` (which releases the GIL and, for a diagonal transform, uses the same fast separable code path as `~scipy.ndimage.zoom`) using the same coordinate mapping as `~scipy.ndimage.zoom` with ``grid_mode=True``. For the 'reflect' and 'mirror' boundary modes, the result is identical to the single `~scipy.ndimage.zoom` call up to floating-point rounding. Some of the other boundary modes (e.g., 'constant' and 'wrap') are handled differently at the edges by `~scipy.ndimage.zoom` with ``grid_mode=True``, so the caller must use this method only for 'reflect' and 'mirror'. scipy < 1.16 emits a UserWarning on the 1-D matrix code path of `~scipy.ndimage.affine_transform` used here, and suppressing it with `warnings.catch_warnings` is not thread-safe, so the caller must also require scipy 1.16 or later. Parameters ---------- data : 2D `~numpy.ndarray` The low-resolution 2D mesh array. zoom_factor : tuple of int The integer zoom factor along each axis. n_threads : int The number of threads to use. Returns ------- result : 2D `~numpy.ndarray` The resized 2D array, with the same dtype as the input. """ out_shape = (data.shape[0] * zoom_factor[0], data.shape[1] * zoom_factor[1]) # The same coordinate mapping as scipy.ndimage.zoom with # grid_mode=True: input_coord = output_coord * ratio + offset zoom_ratio = np.array(data.shape) / np.array(out_shape) offset = 0.5 * zoom_ratio - 0.5 if self.order > 1: filtered = spline_filter(data, self.order, output=np.float64, mode=self.mode) else: filtered = data result = np.empty(out_shape, dtype=data.dtype) def resample_band(y0, y1): band_offset = (y0 * zoom_ratio[0] + offset[0], offset[1]) affine_transform(filtered, zoom_ratio, offset=band_offset, output_shape=(y1 - y0, out_shape[1]), output=result[y0:y1], order=self.order, mode=self.mode, cval=self.cval, prefilter=False) n_bands = min(n_threads, out_shape[0]) band_edges = np.linspace(0, out_shape[0], n_bands + 1).astype(int) with ThreadPoolExecutor(max_workers=n_bands) as executor: list(executor.map(resample_band, band_edges[:-1], band_edges[1:])) return result def __call__(self, data, **kwargs): """ Resize the 2D mesh array. Parameters ---------- data : 2D `~numpy.ndarray` or `~astropy.units.Quantity` The low-resolution 2D mesh array. **kwargs : dict Keyword arguments supplied by `Background2D`. The ``shape`` (output image shape), ``dtype`` (output dtype for a constant mesh), and ``box_size`` (integer zoom factor along each axis) keys are required. ``n_threads`` is optional (see the class Notes). Returns ------- result : 2D `~numpy.ndarray` The resized background or background RMS image. A constant mesh is returned as a constant array of dtype ``dtype`` without interpolation. Otherwise, the result has the dtype of ``data``. Notes ----- If ``data`` is an `~astropy.units.Quantity`, units are stripped before interpolation. Unit reassignment 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'] n_threads = kwargs.get('n_threads', 1) if (n_threads > 1 and SCIPY_GE_1_16 and self.mode in ('reflect', 'mirror')): result = self._threaded_zoom(data, zoom_factor, n_threads) else: 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.'), warning_type=PhotutilsDeprecationWarning) class BkgZoomInterpolator(_BkgZoomInterpolator): """ Class to generate 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', 'mirror', 'constant', 'nearest', 'wrap'}, optional Points outside the boundaries of the input are filled according to the given mode. Default is 'reflect'. See `scipy.ndimage.zoom` for all of the allowed values. 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). The outer pixel edges of the mesh and of the output image are therefore aligned, instead of the centers of their corner pixels. """ 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 version 4.0.'), warning_type=PhotutilsDeprecationWarning) class BkgIDWInterpolator: """ Class to generate 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 : int, 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. The default is 10. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. The default is 10. power : float, optional The power of the inverse distance used for the interpolation weights. The default is 1.0. regularization : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. The default is 0.0. """ 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` or `~astropy.units.Quantity` The low-resolution 2D mesh array. **kwargs : dict Keyword arguments supplied by `Background2D`. The ``shape`` (output image shape), ``dtype`` (output dtype), ``mesh_yxcen`` (the (y, x) centers of the included mesh boxes), and ``mesh_nan_mask`` (mask of the excluded mesh boxes) keys are required. Only the included mesh boxes are used for the interpolation. 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 reassignment 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-24be529/photutils/background/local_background.py000066400000000000000000000304411526452531300256270ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for estimating local background using a circular annulus aperture. """ import astropy.units as u import numpy as np from photutils.aperture import ApertureStats, CircularAnnulus from photutils.background.core import (BiweightLocationBackground, BiweightScaleBackgroundRMS, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) 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`. Nonfinite data values are excluded before it is called. The default is an instance of `~photutils.background.MedianBackground` with sigma clipping (i.e., sigma-clipped median). n_threads : int, optional The number of threads to use to measure the local backgrounds. The default is 1 (no multithreading). The value is passed through to the internal `~photutils.aperture.ApertureStats` computation, which divides the annulus positions into chunks that are processed concurrently, producing results identical to the single-threaded computation. Multithreading is used only when ``bkg_estimator`` is an instance of one of the photutils background or background RMS estimator classes (not a subclass), and for the biweight estimators only with their default ``c`` and ``M``. Any other callable uses the serial per-aperture loop. 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) 1.0 >>> # Multiple positions >>> x = [30, 50, 70] >>> y = [30, 50, 70] >>> bkg = local_bkg(data, x, y) >>> print(bkg) [1. 1. 1.] """ @deprecated_positional_kwargs(since='3.0', until='4.0') def __init__(self, inner_radius, outer_radius, bkg_estimator=None, *, n_threads=1): 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 if (isinstance(n_threads, bool) or not isinstance(n_threads, (int, np.integer)) or n_threads < 1): msg = 'n_threads must be a positive integer' raise ValueError(msg) self.n_threads = int(n_threads) def __repr__(self): params = ('inner_radius', 'outer_radius', 'bkg_estimator', 'n_threads') return make_repr(self, params) def _fast_estimator_spec(self): """ The fast batched estimator specification, or `None`. Returns ``(kind, median_factor, mean_factor)`` when ``bkg_estimator`` is a standard photutils estimator class whose statistic can be computed with the batched `~photutils.aperture.ApertureStats` machinery, and `None` otherwise (in which case the per-aperture loop is used). The biweight-based estimators are supported only with their default tuning constants and location anchors, because the batched biweight kernel uses fixed conventions. """ estimator = self.bkg_estimator median_factor = mean_factor = 0.0 if type(estimator) is MeanBackground: kind = 'mean' elif type(estimator) is MedianBackground: kind = 'median' elif type(estimator) in (ModeEstimatorBackground, MMMBackground): kind = 'mode' median_factor = float(estimator.median_factor) mean_factor = float(estimator.mean_factor) elif type(estimator) is SExtractorBackground: kind = 'sextractor' elif type(estimator) is BiweightLocationBackground: if estimator.c != 6.0 or estimator.M is not None: return None kind = 'biweight_location' elif type(estimator) is StdBackgroundRMS: kind = 'std' elif type(estimator) is MADStdBackgroundRMS: kind = 'mad_std' elif type(estimator) is BiweightScaleBackgroundRMS: if estimator.c != 9.0 or estimator.M is not None: return None kind = 'biweight_scale' else: return None return (kind, median_factor, mean_factor) def _fast_background(self, data, apertures, mask, spec): """ Measure the local backgrounds for all positions using the batched `~photutils.aperture.ApertureStats` machinery. This computes the same statistic as calling the estimator on the finite values of each annulus, but gathers, clips, and reduces the pixels of all apertures in compiled batch kernels instead of looping over the apertures in Python. Parameters ---------- data : 2D `~numpy.ndarray` The data array (without units). apertures : `~photutils.aperture.CircularAnnulus` The annulus apertures. mask : 2D bool `~numpy.ndarray` or `None` The data mask. spec : tuple The estimator specification from `_fast_estimator_spec`. Returns ------- result : 1D float `~numpy.ndarray` The local background values. """ kind, median_factor, mean_factor = spec apstats = ApertureStats(data, apertures, mask=mask, sigma_clip=self.bkg_estimator.sigma_clip, sum_method='center', n_threads=self.n_threads) if kind == 'mean': result = apstats.mean elif kind == 'median': result = apstats.median elif kind == 'mode': result = ((median_factor * apstats.median) - (mean_factor * apstats.mean)) elif kind == 'sextractor': mean = apstats.mean median = apstats.median std = apstats.std 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 with np.errstate(divide='ignore', invalid='ignore'): med_mask = (np.abs(mean - median) / std) >= 0.3 med_mask = np.logical_and(med_mask, np.logical_not(mean_mask)) result[med_mask] = median[med_mask] elif kind == 'biweight_location': result = apstats.biweight_location elif kind == 'std': result = apstats.std elif kind == 'mad_std': result = apstats.mad_std else: # 'biweight_scale' with np.errstate(invalid='ignore'): result = np.sqrt(apstats.biweight_midvariance) return np.asarray(result, dtype=float) 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 array_like of float 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 >>> # Multiple positions >>> aperture = local_bkg.to_aperture([30, 70], [40, 80]) >>> aperture >>> print(len(aperture.positions)) 2 """ x = np.atleast_1d(x) y = np.atleast_1d(y) if x.shape != y.shape: msg = 'x and y must have the same shape' raise ValueError(msg) positions = np.column_stack((x, y)) 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` or `~astropy.units.Quantity` The 2D array on which to measure the local background. x, y : float or 1D array_like of float 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. A scalar is returned if there is a single position. If ``data`` is a `~astropy.units.Quantity`, the result is a `~astropy.units.Quantity` with the same unit. 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) 1.0 >>> # Multiple positions >>> bkg = local_bkg(data, [30, 50], [40, 60]) >>> print(bkg) [1. 1.] >>> # Position outside data returns NaN >>> bkg = local_bkg(data, -50, -50) >>> print(np.isnan(bkg)) True """ unit = None if isinstance(data, u.Quantity): unit = data.unit data = data.value apertures = self.to_aperture(x, y) spec = self._fast_estimator_spec() if spec is not None: bkg = self._fast_background(data, apertures, mask, spec) else: 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) # Exclude nonfinite values, as the batched path does values = values[np.isfinite(values)] bkg[i] = self.bkg_estimator(values) if bkg.size == 1: bkg = bkg[0] if unit is not None: bkg = bkg << unit return bkg astropy-photutils-24be529/photutils/background/tests/000077500000000000000000000000001526452531300231245ustar00rootroot00000000000000astropy-photutils-24be529/photutils/background/tests/__init__.py000066400000000000000000000000001526452531300252230ustar00rootroot00000000000000astropy-photutils-24be529/photutils/background/tests/test_background_2d.py000066400000000000000000001663341526452531300272560ustar00rootroot00000000000000# 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, biweight_location, biweight_scale from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.background import (Background2D, BiweightLocationBackground, BiweightScaleBackgroundRMS, BkgZoomInterpolator, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) from photutils.utils._optional_deps import HAS_MATPLOTLIB from photutils.utils.exceptions import PhotutilsDeprecationWarning @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 nonsquare 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 nonconstant 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 gives sensible results when filter_threshold is set. """ 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 works when filter_threshold is None (the default). """ 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 @pytest.mark.parametrize('filter_threshold', [None, 9.0]) def test_mesh_access_order_independent(self, filter_threshold): """ Test that the background_mesh and background_rms_mesh values do not depend on the order in which they are first accessed, with and without filter_threshold. """ data = np.ones((100, 100)) data[25:50, 50:75] = 10.0 bkg1 = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=filter_threshold) mesh1 = bkg1.background_mesh rms_mesh1 = bkg1.background_rms_mesh bkg2 = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=filter_threshold) rms_mesh2 = bkg2.background_rms_mesh mesh2 = bkg2.background_mesh assert_allclose(mesh1, mesh2) assert_allclose(rms_mesh1, rms_mesh2) @pytest.mark.parametrize('n_threads', [2, 8]) def test_n_threads(self, n_threads): """ Test that multithreaded box statistics give identical results to the single-threaded computation. The data shape is not an integer multiple of the box size, so the extra row, column, and corner boxes are also exercised. """ rng = np.random.default_rng(0) data = rng.normal(1.0, 0.5, (121, 289)) data[50:60, 50:60] = 1000.0 mask = np.zeros(data.shape, dtype=bool) mask[50:60, 50:60] = True bkg1 = Background2D(data, (25, 25), mask=mask) bkg2 = Background2D(data, (25, 25), mask=mask, n_threads=n_threads) assert bkg2.n_threads == n_threads assert_equal(bkg1.background_mesh, bkg2.background_mesh) assert_equal(bkg1.background_rms_mesh, bkg2.background_rms_mesh) assert_equal(bkg1.n_pixels_mesh, bkg2.n_pixels_mesh) # The multithreaded full-size interpolation is identical up to # floating-point rounding assert_allclose(bkg1.background, bkg2.background, rtol=1e-10) assert_allclose(bkg1.background_rms, bkg2.background_rms, rtol=1e-10) def test_n_threads_no_sigma_clip(self): """ Test multithreading with sigma clipping disabled. """ rng = np.random.default_rng(0) data = rng.normal(1.0, 0.5, (100, 100)) bkg1 = Background2D(data, (25, 25), sigma_clip=None) bkg2 = Background2D(data, (25, 25), sigma_clip=None, n_threads=4) assert_equal(bkg1.background_mesh, bkg2.background_mesh) assert_equal(bkg1.background_rms_mesh, bkg2.background_rms_mesh) def test_n_threads_single_box_row(self): """ Test that n_threads larger than the number of box rows falls back to a single-chunk (serial) computation. """ data = np.ones((25, 100)) bkg = Background2D(data, (25, 25), n_threads=8) assert bkg.background_mesh.shape == (1, 4) assert_allclose(bkg.background, data) def test_invalid_n_threads(self, test_data): """ Test that an error is raised if n_threads is not a positive integer. """ match = 'n_threads must be a positive integer' for n_threads in (0, -1, 2.5, True): with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), n_threads=n_threads) 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. The input masks cover all of the nonfinite values, # so no warning should be issued. 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 bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, coverage_mask=coverage_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 nonfinite 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 nonfinite \(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) # A warning is also issued when an input mask is given that # does not cover the nonfinite values mask = np.zeros(test_data.shape, dtype=bool) mask[60:70, 60:70] = True with pytest.warns(AstropyUserWarning, match=match): bkg = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask) 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 nonfinite \(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 nonfinite \(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 nonfinite \(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_exclude_percentile_zero(self, test_data): """ Test that exclude_percentile=0 keeps fully unmasked boxes and excludes boxes with any masked pixels. """ bkg = Background2D(test_data, (25, 25), filter_size=(1, 1), sigma_clip=None, exclude_percentile=0) assert_allclose(bkg.background_mesh, np.ones((4, 4))) # A box with a single masked pixel is excluded. Its mesh value # is then interpolated from the other (unit-valued) boxes. data = np.copy(test_data) data[0:25, 0:25] = 5.0 mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True bkg = Background2D(data, (25, 25), filter_size=(1, 1), sigma_clip=None, mask=mask, exclude_percentile=0) assert_allclose(bkg.background_mesh[0, 0], 1.0) # The same box is included when exclude_percentile=100 bkg = Background2D(data, (25, 25), filter_size=(1, 1), sigma_clip=None, mask=mask, exclude_percentile=100) assert_allclose(bkg.background_mesh[0, 0], 5.0) def test_exclude_percentile_boundary(self): """ Test that a box with exactly exclude_percentile percent masked pixels is included. Only boxes with more than exclude_percentile percent masked pixels are excluded. """ data = np.ones((10, 10)) data[0, 0:10] = 5.0 mask = np.zeros(data.shape, dtype=bool) mask[0, 0:10] = True # Exactly 10% of the single box bkg = Background2D(data, (10, 10), filter_size=(1, 1), sigma_clip=None, mask=mask, exclude_percentile=10.0) assert_allclose(bkg.background_mesh, [[1.0]]) assert bkg.n_pixels_mesh[0, 0] == 90 # More than 10% masked excludes the only box mask[1, 0] = True match = 'All boxes contain fewer than' with pytest.raises(ValueError, match=match): Background2D(data, (10, 10), filter_size=(1, 1), sigma_clip=None, mask=mask, exclude_percentile=10.0) 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), dtype=bool)) 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), dtype=bool)) 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), dtype=bool)) 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), dtype=bool)) @pytest.mark.parametrize('name', ['mask', 'coverage_mask']) @pytest.mark.parametrize('dtype', [float, int]) def test_invalid_mask_dtype(self, name, dtype, test_data): """ Test that an error is raised if the mask or coverage_mask is not a boolean array. """ mask = np.zeros(test_data.shape, dtype=dtype) match = f'{name} must be a boolean array' with pytest.raises(TypeError, match=match): Background2D(test_data, (25, 25), **{name: mask}) @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 nonfinite 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] = True mask[-1, 0] = True mask[-1, -1] = True mask[0, -1] = True 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 nonfinite \(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(PhotutilsDeprecationWarning, match=match): interp = BkgZoomInterpolator() match = "'interpolator' was deprecated" with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): assert bkg.npixels_mesh.shape == (4, 4) with pytest.warns(PhotutilsDeprecationWarning, match=match): assert bkg.npixels_map.shape == data.shape def _make_sigma_clip_biweight(cenfunc='biweight', stdfunc='biweight', **kwargs): """ Create a SigmaClip instance using the 'biweight' cenfunc/stdfunc string options, emulating them on astropy versions that do not support these options. """ def nanbiloc(data, axis=None): return biweight_location(data, axis=axis, ignore_nan=True) def nanbiscale(data, axis=None): return biweight_scale(data, axis=axis, ignore_nan=True) try: return SigmaClip(cenfunc=cenfunc, stdfunc=stdfunc, **kwargs) except ValueError: init_cenfunc = 'median' if cenfunc == 'biweight' else cenfunc init_stdfunc = 'std' if stdfunc == 'biweight' else stdfunc sigma_clip = SigmaClip(cenfunc=init_cenfunc, stdfunc=init_stdfunc, **kwargs) sigma_clip.cenfunc = cenfunc sigma_clip.stdfunc = stdfunc if cenfunc == 'biweight': sigma_clip._cenfunc_parsed = nanbiloc if stdfunc == 'biweight': sigma_clip._stdfunc_parsed = nanbiscale return sigma_clip def _make_generic_background2d(monkeypatch, *args, **kwargs): """ Create a Background2D instance that uses the generic (nonfused) box-statistics path. """ monkeypatch.setattr(Background2D, '_make_box_stats_spec', lambda _self: None) bkg = Background2D(*args, **kwargs) monkeypatch.undo() assert bkg._box_stats_spec is None return bkg class TestFastBoxStatistics: """ Tests for the fused sigma-clipping and box-statistics fast path. The fast path must produce the same results as the generic path based on `astropy.stats.SigmaClip` and the estimator callables. """ @classmethod def setup_class(cls): rng = np.random.default_rng(0) # Shape that is not an integer multiple of the box size, so # the extra row, column, and corner boxes are also exercised data = rng.normal(1.0, 0.5, (121, 289)) data[10:40, 10:40] += 100.0 # outliers to clip data[50:75, 50:75] = 7.0 # constant boxes (zero std) data[0:25, 250:275] = rng.exponential(10.0, (25, 25)) # skewed mask = np.zeros(data.shape, dtype=bool) mask[80:105, 80:105] = True # fully masked box mask[0:10, 25:50] = True # partially masked boxes cls.data = data cls.mask = mask def _compare_paths(self, monkeypatch, *, data=None, **kwargs): """ Compare the fast and generic box-statistics paths. """ if data is None: data = self.data kwargs = {'filter_size': (1, 1), 'exclude_percentile': 100.0, 'mask': self.mask[:data.shape[0], :data.shape[1]], **kwargs} bkg_fast = Background2D(data, (25, 25), **kwargs) assert bkg_fast._box_stats_spec is not None bkg_generic = _make_generic_background2d(monkeypatch, data, (25, 25), **kwargs) assert_allclose(bkg_fast.background_mesh, bkg_generic.background_mesh, rtol=1e-10) assert_allclose(bkg_fast.background_rms_mesh, bkg_generic.background_rms_mesh, rtol=1e-10) assert_equal(bkg_fast.n_pixels_mesh, bkg_generic.n_pixels_mesh) @pytest.mark.parametrize('bkg_estimator', [ MeanBackground(), MedianBackground(), ModeEstimatorBackground(median_factor=2.5, mean_factor=1.5), MMMBackground(), SExtractorBackground(), BiweightLocationBackground()]) @pytest.mark.parametrize('rms_estimator', [StdBackgroundRMS(), MADStdBackgroundRMS(), BiweightScaleBackgroundRMS()]) def test_estimators_match_generic(self, bkg_estimator, rms_estimator, monkeypatch): """ Test that all supported estimator combinations match the generic path. """ self._compare_paths(monkeypatch, bkg_estimator=bkg_estimator, bkg_rms_estimator=rms_estimator) @pytest.mark.parametrize('bkg_estimator', [ BiweightLocationBackground(c=5.0), BiweightLocationBackground(M=2.0)]) @pytest.mark.parametrize('rms_estimator', [ BiweightScaleBackgroundRMS(c=8.0), BiweightScaleBackgroundRMS(M=2.0)]) def test_biweight_variants_match_generic(self, bkg_estimator, rms_estimator, monkeypatch): """ Test that the biweight estimators with nondefault tuning constants and scalar location anchors match the generic path. The test data include constant boxes, for which the biweight statistics are defined by the location anchor (zero MAD). The data are trimmed to an integer number of boxes because the generic path raises an ``AttributeError`` for a float ``M`` anchor when a corner box is computed (an astropy ``biweight_location`` bug for scalar ``M`` with ``axis=None``). The fast path handles it. """ self._compare_paths(monkeypatch, data=self.data[:100, :275], bkg_estimator=bkg_estimator, bkg_rms_estimator=rms_estimator) @pytest.mark.parametrize(('cenfunc', 'stdfunc'), [ ('biweight', 'biweight'), ('biweight', 'std'), ('median', 'biweight')]) def test_biweight_sigma_clip_matches_generic(self, cenfunc, stdfunc, monkeypatch): """ Test that the SigmaClip 'biweight' cenfunc/stdfunc string options are supported by the fused kernel and match the generic path (astropy's Python clipping code paths). """ sigma_clip = _make_sigma_clip_biweight(cenfunc=cenfunc, stdfunc=stdfunc, sigma=2.0, maxiters=5) self._compare_paths(monkeypatch, sigma_clip=sigma_clip) def test_biweight_final_bounds_survivors(self): """ Test that the fused kernel's biweight clipping keeps every value within the final bounds, including a value dropped in an earlier iteration. The value 4 is dropped in the second iteration (upper bound 3.87) and is within the final bounds (upper bound 4.14). """ values = np.array([-2, 1, -2, 1, -5, 1, 1, -6, -3, -3, 2, -4, -2, -3, -2, -1, 6, 2, 4, 2, 0, 1, 1, 3], dtype=float) kept = values n_dropped = 1 while n_dropped: center = biweight_location(kept) scale = biweight_scale(kept) lower = center - 1.5 * scale upper = center + 1.5 * scale survivors = kept[(kept >= lower) & (kept <= upper)] n_dropped = kept.size - survivors.size kept = survivors expected = values[(values >= lower) & (values <= upper)] assert 4.0 not in kept assert 4.0 in expected sigma_clip = _make_sigma_clip_biweight(sigma=1.5, maxiters=20) bkg = Background2D(values.reshape(4, 6), (4, 6), filter_size=1, sigma_clip=sigma_clip, bkg_estimator=MeanBackground(sigma_clip=None), exclude_percentile=100.0) assert bkg._box_stats_spec is not None assert_equal(bkg.n_pixels_mesh, [[expected.size]]) assert_allclose(bkg.background_mesh, [[np.mean(expected)]], rtol=1e-12) def test_biweight_nonfinite_m_falls_back(self, test_data): """ Test that a nonfinite biweight location anchor (M) falls back to the generic path, where the all-NaN result raises an error, rather than silently computing median-anchored values. """ bkg_estimator = BiweightLocationBackground(M=np.nan) match = 'All boxes contain fewer than' with pytest.raises(ValueError, match=match): Background2D(test_data, (25, 25), bkg_estimator=bkg_estimator) @pytest.mark.parametrize('sigma_clip', [ None, SigmaClip(sigma=2.0, maxiters=5), SigmaClip(sigma_lower=2.0, sigma_upper=4.0, maxiters=None), SigmaClip(sigma=3.0, maxiters=10, cenfunc='mean'), SigmaClip(sigma=3.0, maxiters=10, stdfunc='mad_std'), SigmaClip(sigma=2.5, maxiters=1)]) def test_sigma_clip_variants_match_generic(self, sigma_clip, monkeypatch): """ Test that all supported sigma_clip variants match the generic path. """ self._compare_paths(monkeypatch, sigma_clip=sigma_clip) @pytest.mark.parametrize('box_size', [(5, 5), (4, 4), (5, 4)]) @pytest.mark.parametrize('sigma_clip', [ SigmaClip(sigma=2.0, maxiters=None, stdfunc='mad_std'), _make_sigma_clip_biweight(sigma=2.0, maxiters=5)]) def test_mad_boxes_match_generic(self, box_size, sigma_clip, monkeypatch): """ Test that the MAD-based sigma clipping and the MAD standard deviation RMS estimator match the generic path for boxes with odd and even numbers of surviving pixels. The data are skewed and rounded so that the absolute deviations about the box medians contain many ties and the numbers of pixels below and above the median are very unequal. A random mask varies the pixel count from box to box. """ rng = np.random.default_rng(1) data = np.round(rng.exponential(2.0, (40, 60)), 1) mask = rng.random(data.shape) < 0.2 kwargs = {'filter_size': (1, 1), 'exclude_percentile': 100.0, 'mask': mask, 'sigma_clip': sigma_clip, 'bkg_estimator': MedianBackground(), 'bkg_rms_estimator': MADStdBackgroundRMS()} bkg_fast = Background2D(data, box_size, **kwargs) assert bkg_fast._box_stats_spec is not None bkg_generic = _make_generic_background2d(monkeypatch, data, box_size, **kwargs) n_pixels = bkg_fast.n_pixels_mesh assert np.any(n_pixels % 2 == 0) assert np.any(n_pixels % 2 == 1) assert_equal(n_pixels, bkg_generic.n_pixels_mesh) assert_allclose(bkg_fast.background_mesh, bkg_generic.background_mesh, rtol=1e-10) assert_allclose(bkg_fast.background_rms_mesh, bkg_generic.background_rms_mesh, rtol=1e-10) def test_fast_path_used_by_default(self, test_data): """ Test that the fast path is used with the default inputs. """ bkg = Background2D(test_data, (25, 25)) spec = bkg._box_stats_spec assert spec is not None assert spec.bkg_kind == 'sextractor' assert spec.rms_kind == 'std' assert spec.do_clip def test_unsupported_inputs_fall_back(self, test_data): """ Test that unsupported sigma_clip and estimator inputs fall back to the generic path and still produce finite results. """ class UnknownClip: def __call__(self, data, **_kwargs): return np.asarray(data) class MySExtractorBackground(SExtractorBackground): pass def bkg_func(data, *, axis=None): return np.nanmean(data, axis=axis) def rms_func(data, *, axis=None): return np.nanstd(data, axis=axis) unsupported = [ {'sigma_clip': UnknownClip()}, {'sigma_clip': SigmaClip(cenfunc=np.nanmedian)}, {'sigma_clip': SigmaClip(stdfunc=np.nanstd)}, {'sigma_clip': SigmaClip(grow=1.0)}, {'bkg_estimator': MySExtractorBackground()}, {'bkg_estimator': bkg_func}, {'bkg_estimator': BiweightLocationBackground(M=np.array([1.0]))}, {'bkg_rms_estimator': rms_func}, {'bkg_rms_estimator': BiweightScaleBackgroundRMS(M=np.array([1.0]))}, ] for kwargs in unsupported: bkg = Background2D(test_data, (25, 25), **kwargs) assert bkg._box_stats_spec is None assert np.all(np.isfinite(bkg.background_mesh)) def test_degenerate_clip_masks_all(self, monkeypatch): """ Test the degenerate case where sigma clipping converges to an empty set. Every value in the box is clipped, so the box has no surviving pixels and its mesh value is filled by interpolation. This matches astropy's corrected behavior (astropy/astropy#20393). Older astropy versions instead kept every value in this case, so the generic path is only compared when the installed astropy agrees. """ data = np.ones((2, 4)) data[0:2, 2:4] = [[0.0, 0.0], [100.0, 100.0]] sigma_clip = SigmaClip(sigma=0.1, maxiters=10) kwargs = {'box_size': (2, 2), 'filter_size': (1, 1), 'exclude_percentile': 100.0, 'sigma_clip': sigma_clip} bkg_fast = Background2D(data, **kwargs) assert bkg_fast._box_stats_spec is not None assert_equal(bkg_fast.n_pixels_mesh, [[4, 0]]) assert_allclose(bkg_fast.background_mesh, 1.0) # Older astropy versions (<8.0.2) emit a numpy RuntimeWarning # for the NaN bound comparisons in this degenerate case. with np.errstate(invalid='ignore'): clipped = sigma_clip(data[:, 2:4].ravel(), axis=0, masked=True) if not np.all(clipped.mask): pytest.skip('installed astropy keeps all values when sigma ' 'clipping rejects every value') bkg_generic = _make_generic_background2d(monkeypatch, data, **kwargs) assert_equal(bkg_fast.n_pixels_mesh, bkg_generic.n_pixels_mesh) assert_allclose(bkg_fast.background_mesh, bkg_generic.background_mesh, rtol=1e-10) def test_float32_matches_generic(self, monkeypatch): """ Test that float32 input uses the fast path, preserves the dtype, and matches the generic path. """ data = self.data.astype(np.float32) bkg_fast = Background2D(data, (25, 25), filter_size=(1, 1), exclude_percentile=100.0) assert bkg_fast._box_stats_spec is not None assert bkg_fast.background_mesh.dtype == np.float32 bkg_generic = _make_generic_background2d( monkeypatch, data, (25, 25), filter_size=(1, 1), exclude_percentile=100.0) assert bkg_generic.background_mesh.dtype == np.float32 assert_allclose(bkg_fast.background_mesh, bkg_generic.background_mesh, rtol=2e-6) assert_allclose(bkg_fast.background_rms_mesh, bkg_generic.background_rms_mesh, rtol=1e-3, atol=1e-5) def test_n_threads_fast_path(self): """ Test that the multithreaded fast path gives identical results to the single-threaded fast path. """ bkg1 = Background2D(self.data, (25, 25), mask=self.mask, exclude_percentile=100.0) bkg2 = Background2D(self.data, (25, 25), mask=self.mask, exclude_percentile=100.0, n_threads=4) assert_equal(bkg1.background_mesh, bkg2.background_mesh) assert_equal(bkg1.background_rms_mesh, bkg2.background_rms_mesh) assert_equal(bkg1.n_pixels_mesh, bkg2.n_pixels_mesh) def test_masked_array_data_mask(self): """ Test that the mask of masked-array input data is respected and combined with the input mask. """ data_values = np.ones((100, 100)) data_values[0:25, 0:25] = 1e6 data_values[25:50, 25:50] = 1e6 data_mask = np.zeros(data_values.shape, dtype=bool) data_mask[0:25, 0:25] = True data_ma = np.ma.MaskedArray(data_values, mask=data_mask) # Without an input mask, the data mask alone is used mask = np.zeros(data_values.shape, dtype=bool) mask[25:50, 25:50] = True bkg = Background2D(data_ma, (25, 25), filter_size=(1, 1), mask=mask) assert_allclose(bkg.background_mesh, 1.0) assert bkg.n_pixels_mesh[0, 0] == 0 assert bkg.n_pixels_mesh[1, 1] == 0 # A masked array with no masked values (nomask) data_nomask = np.ma.MaskedArray(np.ones((100, 100))) bkg = Background2D(data_nomask, (25, 25), filter_size=(1, 1)) assert_allclose(bkg.background_mesh, 1.0) @pytest.mark.parametrize('dtype', [np.float32, np.float64]) @pytest.mark.parametrize(('bkg_estimator', 'bkg_rms_estimator'), [(MedianBackground(), StdBackgroundRMS()), (SExtractorBackground(), MADStdBackgroundRMS()), (BiweightLocationBackground(), BiweightScaleBackgroundRMS())]) def test_read_only_inputs(dtype, bkg_estimator, bkg_rms_estimator): """ Regression test that read-only (nonwritable) input arrays are accepted, are not modified, and give results identical to writable arrays. """ rng = np.random.default_rng(0) data = rng.normal(10.0, 1.0, (100, 100)).astype(dtype) mask = np.zeros(data.shape, dtype=bool) mask[10:20, 30:40] = True coverage_mask = np.zeros(data.shape, dtype=bool) coverage_mask[:, :5] = True arrays = (data, mask, coverage_mask) originals = [arr.copy() for arr in arrays] def compute(): bkg = Background2D(data, (25, 25), mask=mask, coverage_mask=coverage_mask, bkg_estimator=bkg_estimator, bkg_rms_estimator=bkg_rms_estimator) return (bkg.background, bkg.background_rms, bkg.background_mesh, bkg.background_rms_mesh) expected = compute() for arr in arrays: arr.setflags(write=False) result = compute() for res, exp in zip(result, expected, strict=True): assert_equal(res, exp) for arr, original in zip(arrays, originals, strict=True): assert_equal(arr, original) astropy-photutils-24be529/photutils/background/tests/test_core.py000066400000000000000000000670441526452531300255000ustar00rootroot00000000000000# 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 np.ndim(bkg_val1) == 0 assert np.ndim(bkg_val2) == 0 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)) def test_sextractor_scalar_result_with_axis(self): """ Test that SExtractorBackground returns a scalar when the reduction consumes all axes, consistent with the other estimator classes. """ rng = np.random.default_rng(0) bkg = SExtractorBackground(sigma_clip=None) data = rng.normal(10.0, 1.0, (10, 10)) result = bkg.calc_background(data, axis=(0, 1)) assert np.ndim(result) == 0 data1d = rng.normal(10.0, 1.0, 100) result = bkg.calc_background(data1d, axis=0) assert np.ndim(result) == 0 assert_allclose(result, bkg.calc_background(data1d, axis=None)) @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 @pytest.mark.parametrize('cls', [BiweightLocationBackground, BiweightScaleBackgroundRMS]) @pytest.mark.parametrize('m_value', [40.0, 40, np.float64(40.0), np.array(40.0)]) @pytest.mark.parametrize('axis', [None, 0, 1, -1, (0, 2), (0, 1, 2)]) def test_biweight_scalar_location(self, cls, m_value, axis): """ Test that a scalar ``M`` works for any ``axis`` and gives the same result as an ``M`` array with the shape of the result. """ rng = np.random.default_rng(0) data = rng.normal(40.0, 3.0, (4, 5, 25)) shape = () if axis is None else tuple(np.delete(data.shape, axis)) result = cls(M=m_value, sigma_clip=None)(data, axis=axis) expected = cls(M=np.full(shape, 40.0), sigma_clip=None)(data, axis=axis) assert np.shape(result) == shape assert_allclose(result, expected) # M changes the result default = cls(sigma_clip=None)(data, axis=axis) assert np.any(result != default) 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(rms) 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) class TestFastFlatClip: """ Tests for routing axis=None sigma clipping through astropy's fast C implementation. """ @classmethod def setup_class(cls): rng = np.random.default_rng(0) data = rng.normal(10.0, 2.0, (301, 199)) data[10:20, 10:20] = 200.0 # outliers to clip cls.data = data @pytest.mark.parametrize('est_class', BACKGROUND_CLASSES + BACKGROUND_RMS_CLASSES) @pytest.mark.parametrize('sigma_clip', [ SigmaClip(sigma=2.5, maxiters=10), SigmaClip(sigma_lower=2.0, sigma_upper=4.0, maxiters=None), SigmaClip(sigma=3.0, maxiters=5, cenfunc='mean'), SigmaClip(sigma=3.0, maxiters=5, stdfunc='mad_std')]) def test_matches_slow_path(self, est_class, sigma_clip, monkeypatch): """ Test that the rerouted fast clipping gives the same results as the previous axis=None code path for all estimator classes. """ estimator = est_class(sigma_clip=sigma_clip) result_fast = estimator(self.data) monkeypatch.setattr('photutils.background.core._use_fast_flat_clip', lambda _sigma_clip: False) result_slow = estimator(self.data) assert_allclose(result_fast, result_slow, rtol=1e-12) def test_all_clipped_consistent_with_axis(self): """ Test that when sigma clipping rejects all values, the axis=None result is consistent with the axis-based result. The value itself depends on the astropy version. Older versions of astropy's C implementation kept all values in this degenerate case (giving 50.0), while newer versions mask all values (giving NaN). Both code paths must agree either way. """ data = np.array([0.0, 100.0]) sigma_clip = SigmaClip(sigma=0.1, maxiters=10) bkg = MeanBackground(sigma_clip=sigma_clip) # Older astropy versions emit a numpy RuntimeWarning for the NaN # bound comparisons in this degenerate case. with np.errstate(invalid='ignore'): value = bkg.calc_background(data) value_axis = bkg.calc_background(data[np.newaxis, :], axis=1) assert_allclose(value, value_axis[0]) def test_unsupported_sigma_clip_uses_slow_path(self): """ Test that a sigma_clip with a callable cenfunc (unsupported by the fast C path) still works via the previous code path. """ sigma_clip = SigmaClip(sigma=2.5, maxiters=10, cenfunc=np.nanmedian) bkg1 = MeanBackground(sigma_clip=sigma_clip) bkg2 = MeanBackground(sigma_clip=SigmaClip(sigma=2.5, maxiters=10)) assert_allclose(bkg1(self.data), bkg2(self.data), rtol=1e-12) astropy-photutils-24be529/photutils/background/tests/test_interpolators.py000066400000000000000000000152461526452531300274520ustar00rootroot00000000000000# 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 numpy.testing import assert_allclose from photutils.background.background_2d import Background2D from photutils.background.interpolators import (BkgIDWInterpolator, _BkgZoomInterpolator) from photutils.utils.exceptions import PhotutilsDeprecationWarning @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(PhotutilsDeprecationWarning): interp = BkgIDWInterpolator() with pytest.warns(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning): 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__}') class TestThreadedZoom: """ Tests for the multithreaded row-band zoom in _BkgZoomInterpolator. """ @staticmethod def _make_kwargs(mesh_shape, box_size, *, crop=(0, 0), n_threads=1, dtype=np.float64): shape = (mesh_shape[0] * box_size[0] - crop[0], mesh_shape[1] * box_size[1] - crop[1]) return {'shape': shape, 'dtype': dtype, 'box_size': np.array(box_size), 'n_threads': n_threads} @pytest.mark.parametrize('order', [0, 1, 3, 5]) @pytest.mark.parametrize('mode', ['reflect', 'mirror']) def test_threaded_matches_serial(self, order, mode): """ Test that the multithreaded zoom matches the serial scipy zoom up to floating-point rounding, including with uneven bands, nonsquare meshes and boxes, and output cropping. """ rng = np.random.default_rng(5) mesh = rng.normal(10.0, 3.0, (17, 11)) interp = _BkgZoomInterpolator(order=order, mode=mode) kwargs = self._make_kwargs((17, 11), (13, 7), crop=(4, 3)) ref = interp(mesh, **kwargs) for n_threads in (2, 5, 16): kwargs['n_threads'] = n_threads result = interp(mesh, **kwargs) assert result.shape == kwargs['shape'] assert_allclose(result, ref, rtol=1e-10) def test_threaded_float32(self): """ Test the multithreaded zoom with float32 mesh data. """ rng = np.random.default_rng(5) mesh = rng.normal(10.0, 3.0, (17, 11)).astype(np.float32) interp = _BkgZoomInterpolator() kwargs = self._make_kwargs((17, 11), (13, 7), crop=(4, 3), dtype=np.float32) ref = interp(mesh, **kwargs) kwargs['n_threads'] = 8 result = interp(mesh, **kwargs) assert result.dtype == np.float32 assert_allclose(result, ref, rtol=1e-5) def test_unsupported_mode_falls_back(self): """ Test that boundary modes with grid_mode-specific edge handling fall back to the serial scipy zoom (identical results). """ rng = np.random.default_rng(5) mesh = rng.normal(10.0, 3.0, (9, 9)) interp = _BkgZoomInterpolator(mode='nearest') kwargs = self._make_kwargs((9, 9), (10, 10)) ref = interp(mesh, **kwargs) kwargs['n_threads'] = 8 result = interp(mesh, **kwargs) assert_allclose(result, ref, rtol=0, atol=0) def test_more_threads_than_rows(self): """ Test that n_threads larger than the number of output rows is clamped to the number of rows. """ rng = np.random.default_rng(5) mesh = rng.normal(10.0, 3.0, (2, 3)) interp = _BkgZoomInterpolator() kwargs = self._make_kwargs((2, 3), (2, 2)) ref = interp(mesh, **kwargs) kwargs['n_threads'] = 64 # only 4 output rows result = interp(mesh, **kwargs) assert_allclose(result, ref, rtol=1e-10) astropy-photutils-24be529/photutils/background/tests/test_local_background.py000066400000000000000000000274131526452531300300350ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the local_background 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.aperture import CircularAnnulus from photutils.background import (BiweightLocationBackground, BiweightScaleBackgroundRMS, LocalBackground, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) 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 nonfinite" 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_units(): """ Test that Quantity input data returns a Quantity with the same unit. """ data = np.ones((101, 101)) local_bkg = LocalBackground(5, 10) bkg = local_bkg(data << u.Jy, 50, 50) assert isinstance(bkg, u.Quantity) assert bkg.unit == u.Jy assert_allclose(bkg.value, local_bkg(data, 50, 50)) x = [30, 50, 70] y = [30, 50, 70] bkg2 = local_bkg(data << u.Jy, x, y) assert isinstance(bkg2, u.Quantity) assert bkg2.unit == u.Jy assert_allclose(bkg2.value, local_bkg(data, x, y)) 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_mismatched_shapes(): """ Test that an error is raised if x and y have different shapes. """ local_bkg = LocalBackground(5, 10) match = 'x and y must have the same shape' with pytest.raises(ValueError, match=match): local_bkg.to_aperture([1, 2], [1, 2, 3]) 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 class TestFastLocalBackground: """ Tests that the batched ApertureStats-based fast path gives the same results as the per-aperture estimator loop. """ @classmethod def setup_class(cls): rng = np.random.default_rng(0) data = rng.normal(10.0, 2.0, (151, 151)) data[40:60, 40:60] += 100.0 # outliers to clip mask = np.zeros(data.shape, dtype=bool) mask[100:130, 100:130] = True # fully masks one annulus cls.data = data cls.mask = mask # Interior, clipped-outlier, near-mask, fully-masked, # edge-clipped, and off-image positions cls.x = np.array([30.0, 50.0, 75.0, 115.0, 3.0, -50.0]) cls.y = np.array([30.0, 50.0, 75.0, 115.0, 3.0, -50.0]) def _run_both_paths(self, local_bkg, monkeypatch, data=None): if data is None: data = self.data fast = local_bkg(data, self.x, self.y, mask=self.mask) assert local_bkg._fast_estimator_spec() is not None monkeypatch.setattr(LocalBackground, '_fast_estimator_spec', lambda _self: None) slow = local_bkg(data, self.x, self.y, mask=self.mask) monkeypatch.undo() return fast, slow @pytest.mark.parametrize('estimator', [ MeanBackground(), MedianBackground(), ModeEstimatorBackground(median_factor=2.5, mean_factor=1.5), MMMBackground(), SExtractorBackground(), BiweightLocationBackground(), StdBackgroundRMS(), MADStdBackgroundRMS(), BiweightScaleBackgroundRMS()]) def test_matches_slow_path(self, estimator, monkeypatch): """ Test that all supported estimator classes match the per-aperture loop, including for edge-clipped, fully-masked, and nonoverlapping apertures (NaN). """ local_bkg = LocalBackground(5, 10, bkg_estimator=estimator) fast, slow = self._run_both_paths(local_bkg, monkeypatch) assert np.isnan(fast[3]) # fully masked annulus assert np.isnan(fast[5]) # no overlap with the data assert_allclose(fast, slow, rtol=1e-12) @pytest.mark.parametrize('sigma_clip', [ None, SigmaClip(sigma=2.0, maxiters=5), SigmaClip(sigma=3.0, maxiters=10, cenfunc='mean', stdfunc='mad_std')]) def test_sigma_clip_variants_match_slow_path(self, sigma_clip, monkeypatch): """ Test that estimator sigma_clip variants match the per-aperture loop. """ estimator = MedianBackground(sigma_clip=sigma_clip) local_bkg = LocalBackground(5, 10, bkg_estimator=estimator) fast, slow = self._run_both_paths(local_bkg, monkeypatch) assert_allclose(fast, slow, rtol=1e-12) @pytest.mark.parametrize('sigma_clip', [None, SigmaClip(sigma=3.0)]) @pytest.mark.parametrize('value', [np.nan, np.inf, -np.inf]) def test_nonfinite_data_matches_slow_path(self, value, sigma_clip, monkeypatch): """ Test that nonfinite data values are excluded in both the fast path and the per-aperture loop, with or without sigma clipping. """ data = self.data.copy() data[30:34, 36:40] = value # inside the annulus at (30, 30) estimator = MeanBackground(sigma_clip=sigma_clip) local_bkg = LocalBackground(5, 10, bkg_estimator=estimator) fast, slow = self._run_both_paths(local_bkg, monkeypatch, data=data) assert np.isfinite(fast[0]) assert_allclose(fast, slow, rtol=1e-12) def test_unsupported_estimators_fall_back(self): """ Test that unsupported estimators fall back to the per-aperture loop and still produce finite results. """ class MyMedianBackground(MedianBackground): pass def estimator_func(values): return np.nanmedian(values) # Note: np.float64 anchors are used because a Python float M # triggers an astropy biweight_location bug unsupported = [BiweightLocationBackground(c=5.0), BiweightLocationBackground(M=np.float64(1.0)), BiweightScaleBackgroundRMS(c=8.0), BiweightScaleBackgroundRMS(M=np.float64(1.0)), MyMedianBackground(), estimator_func] data = np.ones((51, 51)) for estimator in unsupported: local_bkg = LocalBackground(5, 10, bkg_estimator=estimator) assert local_bkg._fast_estimator_spec() is None assert np.isfinite(local_bkg(data, 25, 25)) def test_n_threads_identical(self): """ Test that n_threads (passed through to ApertureStats) gives results identical to the single-threaded computation. """ local_bkg1 = LocalBackground(5, 10) local_bkg4 = LocalBackground(5, 10, n_threads=4) assert local_bkg4.n_threads == 4 bkg1 = local_bkg1(self.data, self.x, self.y, mask=self.mask) bkg4 = local_bkg4(self.data, self.x, self.y, mask=self.mask) assert_equal(bkg1, bkg4) def test_n_threads_repr(self): """ Test that n_threads appears in the repr. """ local_bkg = LocalBackground(5, 10, n_threads=4) assert 'n_threads=4' in repr(local_bkg) def test_invalid_n_threads(self): """ Test that an error is raised if n_threads is not a positive integer. """ match = 'n_threads must be a positive integer' for n_threads in (0, -1, 2.5, True): with pytest.raises(ValueError, match=match): LocalBackground(5, 10, n_threads=n_threads) @pytest.mark.parametrize('dtype', [np.float32, np.float64]) @pytest.mark.parametrize('bkg_estimator', [None, MeanBackground(), BiweightLocationBackground()]) def test_read_only_inputs(dtype, bkg_estimator): """ Regression test that read-only (nonwritable) input arrays are accepted, are not modified, and give results identical to writable arrays. """ rng = np.random.default_rng(0) data = rng.normal(10.0, 1.0, (51, 51)).astype(dtype) mask = np.zeros(data.shape, dtype=bool) mask[20:23, 30:33] = True x = np.array([25.2, 3.0, 40.7]) y = np.array([24.6, 47.0, 10.1]) arrays = (data, mask, x, y) originals = [arr.copy() for arr in arrays] local_bkg = LocalBackground(5, 10, bkg_estimator=bkg_estimator) expected = local_bkg(data, x, y, mask=mask) for arr in arrays: arr.setflags(write=False) result = local_bkg(data, x, y, mask=mask) assert_equal(result, expected) for arr, original in zip(arrays, originals, strict=True): assert_equal(arr, original) astropy-photutils-24be529/photutils/background/tests/test_positional_kwargs.py000066400000000000000000000104461526452531300303010ustar00rootroot00000000000000# 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.stats import SigmaClip from photutils.background import (BiweightLocationBackground, BiweightScaleBackgroundRMS, LocalBackground, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) from photutils.utils.exceptions import PhotutilsDeprecationWarning 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(PhotutilsDeprecationWarning, match=match): bkg(self.data, None) match = 'calc_background' with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): bkgrms(self.data, None) match = 'calc_background_rms' with pytest.warns(PhotutilsDeprecationWarning, 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 TestEstimatorInitPositionalKwargs: """ Test that estimator __init__ methods warn for positional optional args. """ @pytest.mark.parametrize(('cls', 'args'), [ (ModeEstimatorBackground, (3.0, 2.0)), (MMMBackground, (SigmaClip(sigma=3.0),)), (BiweightLocationBackground, (6.0,)), (BiweightScaleBackgroundRMS, (9.0,)), ]) def test_init_positional_warns(self, cls, args): match = '__init__' with pytest.warns(PhotutilsDeprecationWarning, match=match): cls(*args) def test_init_keyword_no_warning(self): ModeEstimatorBackground(median_factor=3.0, mean_factor=2.0) MMMBackground(sigma_clip=SigmaClip(sigma=3.0)) BiweightLocationBackground(c=6.0, M=None) BiweightScaleBackgroundRMS(c=9.0, M=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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/background/tests/test_thread_safety.py000066400000000000000000000154051526452531300273640ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the background tools are safe for concurrent use. The estimator classes are stateless after construction. With the default string-based ``cenfunc`` and ``stdfunc`` options, sigma clipping routes through astropy's fast C implementation, which computes its clipping bounds in local variables, so concurrent calls on a shared estimator instance produce results identical to serial calls. A `~astropy.stats.SigmaClip` instance with callable or biweight ``cenfunc``/``stdfunc`` options stores its bounds on the instance during calls and must not be shared between threads. `~photutils.background.Background2D` copies the input data before inserting NaN values for masked pixels, so concurrent constructions may share input arrays. After construction, the instance is immutable except for two idempotent cached properties, so concurrent property reads on a shared instance are safe. `~photutils.background.LocalBackground` holds no mutable state and computes the standard estimators through batched compiled kernels. """ import threading from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal from photutils.background import (Background2D, BiweightLocationBackground, BiweightScaleBackgroundRMS, LocalBackground, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) N_THREADS = 8 N_CALLS = 10 @pytest.fixture(name='image_data') def fixture_image_data(): """ A shared image with sources, outliers, and a mask. """ rng = np.random.default_rng(0) data = rng.normal(loc=5.0, scale=2.0, size=(90, 90)) data[20:25, 30:35] += 100.0 # source data[60, 70] = 1000.0 # outlier for sigma clipping mask = np.zeros(data.shape, dtype=bool) mask[40:45, 40:45] = True return data, mask def run_concurrently(task, n_threads=N_THREADS): """ Run ``task`` in ``n_threads`` threads and return all results. A barrier maximizes the overlap of the concurrent calls. """ barrier = threading.Barrier(n_threads) def worker(): barrier.wait() return [task() for _ in range(N_CALLS)] with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(worker) for _ in range(n_threads)] return [result for future in futures for result in future.result()] BKG_ESTIMATORS = [MeanBackground, MedianBackground, ModeEstimatorBackground, MMMBackground, SExtractorBackground, BiweightLocationBackground] RMS_ESTIMATORS = [StdBackgroundRMS, MADStdBackgroundRMS, BiweightScaleBackgroundRMS] @pytest.mark.parametrize('estimator_cls', BKG_ESTIMATORS + RMS_ESTIMATORS) def test_concurrent_estimator_calls(estimator_cls, image_data): """ Test that concurrent calls to a shared estimator instance with default sigma clipping return results identical to serial calls and do not modify the shared input data. """ data, _ = image_data data_orig = data.copy() estimator = estimator_cls() expected_scalar = estimator(data) expected_rows = estimator(data, axis=1) def task(): return estimator(data), estimator(data, axis=1) for scalar, rows in run_concurrently(task): assert_equal(scalar, expected_scalar) assert_equal(rows, expected_rows) assert_equal(data, data_orig) @pytest.mark.parametrize('n_threads', [1, 2]) def test_concurrent_background2d_construction(n_threads, image_data): """ Test that concurrent Background2D constructions sharing the input data and mask arrays return results identical to a serial construction and do not modify the shared inputs. With ``n_threads`` > 1, each construction also runs its own internal thread pool, exercising nested threading. """ data, mask = image_data data_orig = data.copy() kwargs = {'mask': mask, 'exclude_percentile': 50.0, 'n_threads': n_threads} # A box size that does not evenly divide the data shape # exercises the extra row, column, and corner box paths reference = Background2D(data, (25, 25), **kwargs) def task(): bkg = Background2D(data, (25, 25), **kwargs) return (bkg.background_mesh, bkg.background_rms_mesh, bkg.background, bkg.background_rms) for result in run_concurrently(task, n_threads=4): assert_equal(result[0], reference.background_mesh) assert_equal(result[1], reference.background_rms_mesh) # The multithreaded full-size interpolation is identical up # to floating-point rounding assert_allclose(result[2], reference.background) assert_allclose(result[3], reference.background_rms) assert_equal(data, data_orig) def test_concurrent_background2d_shared_instance(image_data): """ Test that concurrent property reads on a shared Background2D instance, including the cold first accesses of the cached median properties, return results identical to serial reads. """ data, mask = image_data bkg = Background2D(data, (30, 30), mask=mask, exclude_percentile=50.0) reference = Background2D(data, (30, 30), mask=mask, exclude_percentile=50.0) expected = (reference.background, reference.background_rms, reference.background_median, reference.background_rms_median, reference.n_pixels_map) def task(): return (bkg.background, bkg.background_rms, bkg.background_median, bkg.background_rms_median, bkg.n_pixels_map) for result in run_concurrently(task): for value, expect in zip(result, expected, strict=True): assert_equal(value, expect) def test_concurrent_local_background(image_data): """ Test that concurrent calls to a shared LocalBackground instance return results identical to a serial call, for both a standard estimator (batched kernel path) and a custom estimator callable (per-aperture loop path). """ data, mask = image_data x = np.array([20.0, 45.5, 70.0]) y = np.array([25.0, 45.5, 65.0]) local_bkgs = [LocalBackground(5, 10), LocalBackground(5, 10, bkg_estimator=np.nanmedian)] for local_bkg in local_bkgs: expected = local_bkg(data, x, y, mask=mask) def task(local_bkg=local_bkg): return local_bkg(data, x, y, mask=mask) for result in run_concurrently(task): assert_equal(result, expected) astropy-photutils-24be529/photutils/centroids/000077500000000000000000000000001526452531300216355ustar00rootroot00000000000000astropy-photutils-24be529/photutils/centroids/__init__.py000066400000000000000000000003221526452531300237430ustar00rootroot00000000000000# 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-24be529/photutils/centroids/_utils.py000066400000000000000000000200451526452531300235070ustar00rootroot00000000000000# 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 nonfinite 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 nonfinite data values. Returns ------- data : `~numpy.ndarray` Processed data with masked and nonfinite values replaced by ``fill_value``. A `~numpy.ma.MaskedArray` input is returned as a plain `~numpy.ndarray`. """ data = _validate_data(data, ndim=ndim) is_copied = False _validate_mask_shape(data, mask) # Work on the plain array of a MaskedArray. An assignment to a # masked element of an array with a hard mask is silently ignored, # so its masked values could not be filled. if isinstance(data, np.ma.MaskedArray): mask2 = np.ma.getmaskarray(data) if np.any(mask2): mask = mask2 if mask is None else mask | mask2 data = data.data badmask = ~np.isfinite(data) 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 nonfinite 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 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 array_like 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, nonfinite data, and nonfinite 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, copying 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-24be529/photutils/centroids/core.py000066400000000000000000001457171526452531300231560ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for centroiding sources. """ import inspect import numbers import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning from scipy.interpolate import RectBivariateSpline from scipy.ndimage import gaussian_filter, map_coordinates from scipy.optimize import minimize from photutils.centroids._utils import _process_data_mask from photutils.utils._deprecation import (deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._overlap import overlap_slices 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', 'centroid_symmetry'] @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 `_. Nonfinite values (e.g., NaN or inf) in the ``data`` array are automatically masked. An `~astropy.utils.exceptions.AstropyUserWarning` is emitted if any of them is not already masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for nonfinite 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` or a `~astropy.units.Quantity`. 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 absolute value of the sum of the (unmasked) data is smaller than 1e-30 (i.e., consistent with 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(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] def centroid_symmetry(data, *, mask=None, radius=None): """ Calculate the center of a 2D array as its point of maximal point symmetry. The center is the position about which the data within ``radius`` are most symmetric under a rotation by 180 degrees. It is found by minimizing the mean of the absolute differences between the data values at opposite offsets from the center. This is the definition of the center of an effective PSF (ePSF) of `Anderson 2016 (WFC3 ISR 2016-12) `_. Unlike the center of mass (`centroid_com`), it depends only on the core of the source and is insensitive to asymmetric structure beyond ``radius``. Nonfinite values (e.g., NaN or inf) in the ``data`` array are automatically masked. An `~astropy.utils.exceptions.AstropyUserWarning` is emitted if any of them is not already masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for nonfinite values, and the mask of the input ``data`` if it is a `~numpy.ma.MaskedArray`. The pairs of opposite offsets with a position near a masked value are given a lower weight or are not used. .. warning:: The result is less accurate if masked or nonfinite values lie within ``radius`` of the center. The data are interpolated between the pixels, so a masked value is first replaced by a weighted mean of the unmasked values around it, which is only an estimate. In tests with a noiseless Gaussian source in a 21x21 array, the default ``radius``, and a single masked pixel adjacent to its peak pixel, the result changed by up to 0.002 pixels for a standard deviation of 2 pixels, 0.03 pixels for 1.2 pixels, and 0.1 pixels for 0.9 pixels. The change is larger for a smaller ``radius``. It was up to 0.01 pixels for a radius of 3 pixels and a standard deviation of 2 pixels, and up to 0.2 pixels for a radius of 1.5 pixels at any of these standard deviations. The search starts at the maximum value near the center of the array. A bright outlier such as a hot pixel or a cosmic ray near the source should be masked, because a single bright pixel is symmetric about itself. Parameters ---------- data : 2D array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray` or a `~astropy.units.Quantity`. The image should be a background-subtracted cutout image containing a single positive source near its center. It must have at least 4 pixels along each axis. 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``. radius : float or `None`, optional The radius (in pixels) of the circular region around the center in which the symmetry is measured. If `None`, the radius is 0.3 times the smaller dimension of ``data`` (e.g., 1.5 pixels for a 5x5 array). The radius must be at least 1 pixel and smaller than half of the smaller dimension of ``data`` minus one half. The default is intended for a small cutout of the core of a source. For a cutout that is much larger than the core, set ``radius`` to about the size of the core. A larger radius includes pairs of background values, which add noise to the result. Returns ------- centroid : `~numpy.ndarray` The ``(x, y)`` coordinates of the center. An array of NaN values is returned if no pair of opposite offsets is unmasked, if the unmasked data values are all equal, or if all of the values in the area that is searched for the center are masked. See Also -------- centroid_com, centroid_quadratic Notes ----- The asymmetry that is minimized is .. math:: A(x_c, y_c) = \\frac{1}{N} \\sum_{|u| \\le r} \\left| I(x_c + u_x, y_c + u_y) - I(x_c - u_x, y_c - u_y) \\right| where the sum is over the :math:`N` pairs of opposite offsets :math:`\\pm u` on a grid within the radius :math:`r`, and :math:`I` is the data interpolated with a bicubic spline. The spacing of the grid is one pixel for a radius of 6 pixels or more and half a pixel for a smaller radius, so that a small region is still sampled by enough pairs. If there are masked values, the mean is a weighted mean. The weight of a position decreases linearly from 1 to 0 as it moves from an unmasked pixel halfway to a masked pixel, and the weight of a pair is the product of the weights of its two positions. The interpolation is less accurate for an undersampled source. For a noiseless Gaussian source integrated over the pixels, the error of the result is up to about 0.02 pixels for a standard deviation of 0.6 to 0.8 pixels (a FWHM of 1.4 to 1.9 pixels), 0.01 pixels for 1 pixel, 0.005 pixels for 1.2 pixels, and less than 0.002 pixels for 1.5 pixels or more. The error increases quickly for a narrower source (0.05 pixels for a standard deviation of 0.5 pixels), and it is larger for a radius of less than 2 pixels, which is the default for an array with fewer than 7 pixels along an axis. The region must stay within the array, so the center is searched only within ``(n - 1) / 2 - radius`` pixels of the center of the array along each axis, where ``n`` is the size of the array along that axis. The source should therefore be roughly centered in ``data``. If the center of the source lies outside of that area, the returned position is on its edge. A region without a source is also symmetric, so the search starts at the maximum value near that area and finds the minimum of the asymmetry nearest to it. The source must therefore be positive. If the source lies far outside of that area, the asymmetry decreases away from the source and the search moves away from it. This case is recognized by the mean of the data values at the center and at the offsets within ``radius``, which is then less than half of its value at the starting position. The returned position is then the nearby position in that area where this mean is largest, which for a single source is the position nearest to it. The mean uses the estimates that replace the masked values. The two means are compared as a ratio, so the data must be background subtracted, and this case is not recognized if the mean at the starting position is not positive. If the noise is comparable to the data values near that area, the source cannot be recognized and the returned position is not meaningful. Examples -------- >>> import numpy as np >>> from photutils.centroids import centroid_symmetry >>> 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] >>> x1, y1 = centroid_symmetry(data) >>> print(np.array((x1, y1))) [19.98462513 20.0077986 ] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_symmetry 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_symmetry(data) fig, ax = plt.subplots(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=2, fill_value=np.nan) ny, nx = data.shape if min(ny, nx) < 4: msg = 'data must have at least 4 pixels along each axis' raise ValueError(msg) half_size = (min(ny, nx) - 1) / 2 if radius is None: radius = 0.3 * min(ny, nx) if not isinstance(radius, numbers.Real): msg = 'radius must be a real number or None' raise TypeError(msg) if not 1 <= radius < half_size: msg = ('radius must be at least 1 and less than half of the ' 'smaller dimension of data minus one half') raise ValueError(msg) # Constant data are symmetric about every position bad = ~np.isfinite(data) if np.all(bad) or np.ptp(data[~bad]) == 0: return np.full(2, np.nan) # The spline needs finite values, and a masked value also changes # the spline at the adjacent positions. The masked values are # therefore replaced by a Gaussian-weighted mean of the unmasked # values around them. The pairs near a masked value are given a # lower weight in _asymmetry. if np.any(bad): total = gaussian_filter(np.where(bad, 0.0, data), 1.0, mode='constant') weight = gaussian_filter((~bad).astype(float), 1.0, mode='constant') fill = np.divide(total, weight, out=np.zeros_like(total), where=weight > 0) data = np.where(bad, fill, data) good = (~bad).astype(float) else: bad = None good = None spline = RectBivariateSpline(np.arange(ny), np.arange(nx), data) # One offset of each opposite pair within the radius. A small # radius has few pairs at a spacing of one pixel, so the spacing is # then half a pixel. Other fractions of a pixel are much less # accurate for an undersampled source. spacing = 0.5 if radius < 6 else 1.0 n_max = int(radius / spacing) y_off, x_off = np.mgrid[-n_max:n_max + 1, -n_max:n_max + 1] * spacing keep = ((np.hypot(x_off, y_off) <= radius) & ((y_off > 0) | ((y_off == 0) & (x_off > 0)))) x_off = x_off[keep] y_off = y_off[keep] args = (spline, x_off, y_off, good) # The region must stay within the array x_center = (nx - 1) / 2 y_center = (ny - 1) / 2 x_margin = x_center - radius y_margin = y_center - radius x_bounds = (x_center - x_margin, x_center + x_margin) y_bounds = (y_center - y_margin, y_center + y_margin) # A featureless region is also symmetric, so the search starts at # the maximum value and a coarse search is made only within one # pixel of it. The pixels that partly overlap the allowed region # are included, because the allowed region of an array with an even # size can lie between the pixel centers. yslc = slice(int(np.floor(y_bounds[0])), int(np.ceil(y_bounds[1])) + 1) xslc = slice(int(np.floor(x_bounds[0])), int(np.ceil(x_bounds[1])) + 1) region = data[yslc, xslc] if bad is not None: region = np.where(bad[yslc, xslc], -np.inf, region) if not np.any(np.isfinite(region)): return np.full(2, np.nan) ypeak, xpeak = np.unravel_index(np.argmax(region), region.shape) xy_peak = np.array((np.clip(xpeak + xslc.start, *x_bounds), np.clip(ypeak + yslc.start, *y_bounds))) steps = np.linspace(-1.0, 1.0, 5) x_grid = np.unique(np.clip(xpeak + xslc.start + steps, *x_bounds)) y_grid = np.unique(np.clip(ypeak + yslc.start + steps, *y_bounds)) values = np.array([[_asymmetry((x, y), *args) for x in x_grid] for y in y_grid]) if not np.any(np.isfinite(values)): return np.full(2, np.nan) yidx, xidx = np.unravel_index(np.argmin(values), values.shape) x_init = x_grid[xidx] y_init = y_grid[yidx] # The default initial simplex of Nelder-Mead has a size that is # proportional to the starting coordinates. For a large array it # would reach the featureless region around the source. The steps # here are a fraction of a pixel and they point toward the center # of the allowed region to stay within the bounds. # # The scale of the asymmetry depends on the data, so only the size # of the simplex is used to stop the search. # # The positions outside of the bounds are moved onto them, so a # simplex next to an edge can collapse onto it and then never leave # it, even if the minimum is a fraction of the step inside. If the # asymmetry is lower just inside of a result on an edge, the search # is therefore repeated from that result with a smaller step. bounds = (x_bounds, y_bounds) xy_center = np.array((x_center, y_center)) xy_margin = np.array((x_margin, y_margin)) xatol = 1.0e-5 step = 0.25 while True: x_step, y_step = (np.minimum(step, xy_margin) * np.where((x_init, y_init) <= xy_center, 1, -1)) initial_simplex = [(x_init, y_init), (x_init + x_step, y_init), (x_init, y_init + y_step)] options = {'initial_simplex': initial_simplex, 'xatol': xatol, 'fatol': np.inf} result = minimize(_asymmetry, (x_init, y_init), args=args, method='Nelder-Mead', bounds=bounds, options=options) xycen = np.array(result.x) step /= 4 if step < xatol or not _is_lower_inside(xycen, result.fun, args, bounds, 10 * xatol): break x_init, y_init = xycen # If the source lies far outside of the allowed region, the region # around the starting position is beyond the wings of the source # where the asymmetry decreases away from the source, and the search # moves away from the source. The mean of the data values within # the radius is then much lower at the result than at the starting # position. For a source in the allowed region it is about the same # or larger, because the brightest pixel is near the source. The # position in the allowed region that is nearest to the source is # the one where the mean is largest. The mean is a smooth function # of the position, so a gradient-based search is used. It starts at # the brightest pixel so that it is not drawn to another source. # # The factor of 0.5 separates the two cases in tests with Gaussian # sources. The ratio of the two means was at least 0.79 for sources # in the allowed region with noise of up to 30% of the peak value, # and at most 0.40 for noiseless sources at 1.8 to 3 times the size # of the allowed region from its center. The ratio has no meaning # if the mean at the starting position is not positive. mean_args = (spline, x_off, y_off, np.ptp(data)) peak_mean = -_negative_mean(xy_peak, *mean_args)[0] result_mean = -_negative_mean(xycen, *mean_args)[0] if peak_mean > 0 and result_mean < 0.5 * peak_mean: result = minimize(_negative_mean, xy_peak, args=mean_args, method='L-BFGS-B', jac=True, bounds=bounds, options={'ftol': 0.0, 'gtol': 1.0e-10}) xycen = np.array(result.x) return xycen def _is_lower_inside(xy, value, args, bounds, step): """ Determine whether the asymmetry is lower just inside of a position on an edge of the allowed region. Parameters ---------- xy : 1D `~numpy.ndarray` The ``(x, y)`` position. value : float The asymmetry at ``xy``. args : tuple The arguments of `_asymmetry` after the trial center. bounds : tuple of 2 tuples of 2 floats The ``(lower, upper)`` bounds of the allowed region along the x- and y-axes. step : float The distance (in pixels) from the edge at which the asymmetry is calculated. Returns ------- result : bool `True` if ``xy`` is on an edge and the asymmetry is lower at a position that is ``step`` inside of it along any of the axes with an edge or along all of them. """ lower, upper = np.transpose(bounds) inward = (np.isclose(xy, lower, rtol=0.0, atol=0.1 * step).astype(float) - np.isclose(xy, upper, rtol=0.0, atol=0.1 * step)) axes = np.flatnonzero(inward) trials = [inward * (np.arange(2) == axis) for axis in axes] if axes.size == 2: trials.append(inward) return any(_asymmetry(xy + step * trial, *args) < value for trial in trials) def _negative_mean(xy, spline, x_off, y_off, scale): """ Calculate the negative of the mean of the data values at a trial center and at the pairs of opposite offsets from it, and its gradient. Parameters ---------- xy : tuple of 2 floats The ``(x, y)`` trial center. spline : `~scipy.interpolate.RectBivariateSpline` The spline that interpolates the data. x_off, y_off : 1D `~numpy.ndarray` The x and y offsets of one position of each opposite pair. scale : float The value by which the mean is divided, which makes the gradient independent of the scale of the data. Returns ------- result : float The negative of the scaled mean of the data values. gradient : 1D `~numpy.ndarray` The derivatives of ``result`` with respect to ``x`` and ``y``. """ x_pos = xy[0] + np.concatenate((x_off, -x_off, (0.0,))) y_pos = xy[1] + np.concatenate((y_off, -y_off, (0.0,))) result = -np.mean(spline.ev(y_pos, x_pos)) / scale gradient = -np.array((np.mean(spline.ev(y_pos, x_pos, dy=1)), np.mean(spline.ev(y_pos, x_pos, dx=1)))) / scale return result, gradient def _asymmetry(xy, spline, x_off, y_off, good): """ Calculate the weighted mean absolute difference of the data values at opposite offsets from a trial center. Parameters ---------- xy : tuple of 2 floats The ``(x, y)`` trial center. spline : `~scipy.interpolate.RectBivariateSpline` The spline that interpolates the data. x_off, y_off : 1D `~numpy.ndarray` The x and y offsets of one position of each opposite pair. good : 2D float `~numpy.ndarray` or `None` An array that is 1 where the data values are unmasked and 0 where they are masked. The weight of a position decreases linearly from 1 to 0 as it moves from an unmasked pixel halfway to a masked pixel, and the weight of a pair is the product of the weights of its two positions. The weights are a continuous function of the trial center, which keeps the asymmetry continuous. If `None`, all of the pairs have equal weight. Returns ------- result : float The asymmetry about the trial center. It is infinite if every pair has zero weight. """ x1 = xy[0] + x_off y1 = xy[1] + y_off x2 = xy[0] - x_off y2 = xy[1] - y_off diff = np.abs(spline.ev(y1, x1) - spline.ev(y2, x2)) if good is None: return np.mean(diff) weights = 1.0 for x, y in ((x1, y1), (x2, y2)): frac = map_coordinates(good, (y, x), order=1, mode='nearest') weights = weights * np.clip(2.0 * frac - 1.0, 0.0, 1.0) total = np.sum(weights) if total <= 0: return np.inf return np.sum(weights * diff) / total @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. Nonfinite values (e.g., NaN or inf) in the ``data`` array are automatically masked. An `~astropy.utils.exceptions.AstropyUserWarning` is emitted if any of them is not already masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for nonfinite 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) [1]_ 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` or a `~astropy.units.Quantity`. 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 and the box must contain at least 6 pixels. A size larger than ``data`` along an axis is reduced to the largest odd size that fits, with a warning. xpeak, ypeak : float or `None`, optional The initial guess of the position of the centroid. If both are `None`, the position of the maximum value in the input ``data`` is used as the initial guess. A `ValueError` is raised if only one of them is `None` or if the position is outside of ``data``. .. deprecated:: 3.0 The ``xpeak`` and ``ypeak`` keywords are deprecated and will be removed in version 4.0. 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 ``xpeak`` and ``ypeak`` are `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 version 4.0. 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) [1]_ for their 2D second-order polynomial centroiding method. Because this centroid is based on fitting data, it can fail for several reasons. In each of these cases, an `~astropy.utils.exceptions.AstropyUserWarning` is emitted and ``(np.nan, np.nan)`` is returned: * quadratic fit failed * quadratic fit does not have a maximum * quadratic fit maximum falls outside image * not enough unmasked data points (6 are required) A `ValueError` is raised if all data values are masked or nonfinite. Also note that a fit is not performed if the maximum data value is at the edge of the data. In this case, a warning is emitted and the position of the maximum pixel is 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(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 if not np.any(np.isfinite(data)): msg = 'All data values are masked or nonfinite' raise ValueError(msg) 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. The fit # coordinates are centered on the peak pixel to keep the design # matrix well conditioned. With absolute coordinates the condition # number grows as ~coordinate**4 and the fit fails for sources at # large pixel coordinates (e.g., in large mosaic images). xi = np.arange(xidx0, xidx1) - xidx yi = np.arange(yidx0, yidx1) - yidx x, y = np.meshgrid(xi, yi) x = x.ravel() y = y.ravel() # Preallocate 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)) # Add back the peak-pixel offset to convert the analytic maximum # from fit coordinates to data coordinates xm = (c01 * c11 - 2.0 * c02 * c10) / det + xidx ym = (c10 * c11 - 2.0 * c20 * c01) / det + yidx 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 and the box must contain at least 6 pixels. A size larger than ``data`` along an axis is reduced to the largest odd size that fits, with a warning. The value is validated when the instance is called. 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))) [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. Nonfinite values (e.g., NaN or inf) in the ``data`` array are automatically masked. An `~astropy.utils.exceptions.AstropyUserWarning` is emitted if any of them is not already 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` or a `~astropy.units.Quantity`. 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. See `~photutils.centroids.centroid_quadratic` for the cases in which a warning is emitted and NaN values or the position of the maximum pixel are returned. """ 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 nonrectangular cutout image. Masks and nonfinite values are handled by the input ``centroid_func``. When using a centroid function provided by Photutils, nonfinite 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 nonfinite ``error`` array values. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask(s) for nonfinite 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 `~numpy.ndarray` 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 sources. A cutout image centered on each position will be used to calculate the centroid. The positions must be finite and within ``data``. 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 cut out. ``footprint`` can be used to create a nonrectangular cutout image, in which case the input ``xpos`` and ``ypos`` represent the center of the ``footprint`` array. ``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. A callable whose signature accepts arbitrary keyword arguments (``**kwargs``) is assumed to handle a ``mask`` 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``. A `TypeError` is raised for keyword arguments not accepted by the ``centroid_func``. An ``error`` keyword must be an array with the same shape as ``data``. The cutout of it for each source is passed to the ``centroid_func``. ``error=None`` is ignored. Returns ------- xcentroid, ycentroid : `~numpy.ndarray` The 1D arrays of the ``x`` and ``y`` pixel positions of the centroids. If the ``centroid_func`` raises a `ValueError` or `TypeError` for a source, an `~astropy.utils.exceptions.AstropyUserWarning` is emitted and NaN is returned for that source. This is usually due to a ``box_size`` that is too small when using a fitting-based centroid function (e.g., `centroid_1dg`, `centroid_2dg`, or `centroid_quadratic`). A `ValueError` is raised if the cutout of a source is completely masked. 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) [ 24.96807828 89.98684636 149.96545721 160.18810915] >>> print(y) [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 not (np.all(np.isfinite(xpos)) and np.all(np.isfinite(ypos))): msg = 'xpos and ypos must contain only finite values' 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 not np.any(footprint): msg = 'footprint must contain at least one True value' 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) # Setting error to None is equivalent to no error array, so allow it # even for centroid functions that do not accept an error keyword if kwargs.get('error') is None: kwargs.pop('error', None) # Allow arbitrary keyword arguments (**kwargs) spec = inspect.signature(centroid_func) accepts_var_keyword = any(param.kind == inspect.Parameter.VAR_KEYWORD for param in spec.parameters.values()) if 'mask' not in spec.parameters and not accepts_var_keyword: msg = "The input 'centroid_func' must have a 'mask' keyword." raise ValueError(msg) if not accepts_var_keyword: unknown_keys = set(kwargs) - set(spec.parameters) if unknown_keys: msg = ('Unrecognized keyword argument(s) for the input ' f"'centroid_func': {sorted(unknown_keys)}") raise TypeError(msg) centroid_kwargs = dict(kwargs) # Save the original error array so that each source independently # slices the full-image array error_array = centroid_kwargs.pop('error', None) if error_array is not None and np.shape(error_array) != data.shape: msg = 'error and data must have the same shape' raise ValueError(msg) # Extract xpeak/ypeak so the original absolute coordinates are # available for every source. The per-source function below adds # them back with the correct cutout offset. # Remove this block once xpeak and ypeak are fully deprecated. xpeak_orig = centroid_kwargs.pop('xpeak', None) ypeak_orig = centroid_kwargs.pop('ypeak', None) inverted_footprint = np.logical_not(footprint) def _centroid_source(xypos): """ Compute the centroid of the source at the given (x, y) position. """ xp, yp = xypos 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) # Build the per-source keyword arguments from a local copy so # that no shared state is mutated across sources src_kwargs = dict(centroid_kwargs) src_kwargs['mask'] = mask_cutout if error_array is not None: src_kwargs['error'] = error_array[slices_large] # Add xpeak/ypeak with the offset relative to this source's # cutout. # Remove this block once xpeak and ypeak are fully deprecated. if xpeak_orig is not None and ypeak_orig is not None: src_kwargs['xpeak'] = xpeak_orig - slices_large[1].start src_kwargs['ypeak'] = ypeak_orig - slices_large[0].start try: xcen, ycen = centroid_func(data_cutout, **src_kwargs) except (ValueError, TypeError) as exc: msg = f'Centroid failed for source at ({xp}, {yp}): {exc}' warnings.warn(msg, AstropyUserWarning) xcen, ycen = np.nan, np.nan return (xcen + slices_large[1].start, ycen + slices_large[0].start) results = [_centroid_source(xypos) for xypos in zip(xpos, ypos, strict=True)] results = np.array(results, dtype=float) return results[:, 0], results[:, 1] astropy-photutils-24be529/photutils/centroids/gaussian.py000066400000000000000000000232311526452531300240220ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for centroiding sources using Gaussians. """ import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Gaussian1D, Gaussian2D 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. Nonfinite values (e.g., NaN or inf) in the ``data`` or ``error`` arrays are automatically masked. An `~astropy.utils.exceptions.AstropyUserWarning` is emitted if any nonfinite ``data`` value is not already masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for nonfinite values, and the mask of the input ``data`` if it is a `~numpy.ma.MaskedArray`. Masked pixels are excluded by substituting zero into the marginal sums, and the fit weights are zeroed only for fully masked rows or columns. A partially masked row or column near the source peak therefore distorts the corresponding marginal distribution and can bias the centroid. Consider using `~photutils.centroids.centroid_2dg`, which excludes individual masked pixels from the fit, when isolated masked or nonfinite pixels fall near the source peak. A `ValueError` is raised if either marginal distribution sums to zero or is constant (e.g., if all of the data are masked). The fitter emits an `~astropy.utils.exceptions.AstropyUserWarning` if a fit did not converge. Parameters ---------- data : 2D array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray` or a `~astropy.units.Quantity`. The image should be a background-subtracted cutout image containing a single source. error : 2D array_like, optional The 2D array of the 1-sigma errors of the input ``data``. If ``data`` has units, ``error`` must have the same units. 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(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)] # A zero-sum or constant marginal distribution has undefined # moment estimates (0/0) and cannot be fit with a 1D Gaussian. for data_i in xy_data: if np.sum(data_i) == 0 or np.ptp(data_i) == 0: msg = ('Input data must have a nonzero sum and nonconstant ' 'values to fit 1D Gaussians.') raise ValueError(msg) # 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. Nonfinite values (e.g., NaN or inf) in the ``data`` or ``error`` arrays are automatically masked. An `~astropy.utils.exceptions.AstropyUserWarning` is emitted if any nonfinite ``data`` value is not already masked. The final mask is a logical OR combination of the input ``mask``, the automatically generated mask for nonfinite 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 `ValueError` is raised if there are fewer than 6 unmasked values or if the unmasked data are constant. The fitter emits an `~astropy.utils.exceptions.AstropyUserWarning` if the fit did not converge. Parameters ---------- data : 2D array_like The 2D image data. ``data`` can be a `~numpy.ma.MaskedArray` or a `~astropy.units.Quantity`. The image should be a background-subtracted cutout image containing a single source. error : 2D array_like, optional The 2D array of the 1-sigma errors of the input ``data``. If ``data`` has units, ``error`` must have the same units. The values should be positive. A zero or negative value gives that pixel a very large weight in the fit. 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(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 at least 6 unmasked values to fit a ' '2D Gaussian.') raise ValueError(msg) # The masked pixels are zero in data, so only the unmasked values # are tested if np.ptp(data[~mask]) == 0: msg = ('Input data must have nonconstant values to fit a ' '2D Gaussian.') raise ValueError(msg) # Subtract the minimum of the unmasked data to make the data values # nonnegative. Moments from negative data values can yield # undefined Gaussian parameters, e.g., x_stddev and y_stddev. The # masked pixels are kept at zero so that they do not contribute to # the moments. shifted = data - np.min(data[~mask]) shifted[mask] = 0.0 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) # The fitter emits an AstropyUserWarning if the fit did not converge gfit = fitter(g_init, x, y, data, weights=weights) return np.array([gfit.x_mean.value, gfit.y_mean.value]) astropy-photutils-24be529/photutils/centroids/tests/000077500000000000000000000000001526452531300227775ustar00rootroot00000000000000astropy-photutils-24be529/photutils/centroids/tests/__init__.py000066400000000000000000000000001526452531300250760ustar00rootroot00000000000000astropy-photutils-24be529/photutils/centroids/tests/helpers.py000066400000000000000000000016501526452531300250150ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Helper functions for centroid tests. """ import numpy as np from astropy.modeling.models import Gaussian2D __all__ = ['make_gaussian_source'] def make_gaussian_source(shape, amplitude, xc, yc, xstd, ystd, theta): """ Make a 2D Gaussian source. Parameters ---------- shape : tuple of 2 int The (ny, nx) shape of the output image. amplitude : float The amplitude of the Gaussian. xc, yc : float The center of the Gaussian. xstd, ystd : float The standard deviations of the Gaussian. theta : float The rotation angle of the Gaussian in radians. Returns ------- data : 2D `~numpy.ndarray` The 2D image of the Gaussian source. """ yy, xx = np.mgrid[0:shape[0], 0:shape[1]] model = Gaussian2D(amplitude, xc, yc, xstd, ystd, theta) return model(xx, yy) astropy-photutils-24be529/photutils/centroids/tests/test_core.py000066400000000000000000001475761526452531300253640ustar00rootroot00000000000000# 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 AstropyUserWarning from numpy.testing import assert_allclose, assert_array_equal from photutils.centroids.core import (CentroidQuadratic, centroid_com, centroid_quadratic, centroid_sources, centroid_symmetry) from photutils.centroids.gaussian import centroid_1dg, centroid_2dg from photutils.centroids.tests.helpers import make_gaussian_source from photutils.datasets import make_4gaussians_image, make_noise_image from photutils.psf import CircularGaussianPRF from photutils.utils.exceptions import PhotutilsDeprecationWarning @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 nonfinite 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 @pytest.mark.parametrize('centroid_func', [centroid_com, centroid_symmetry, centroid_quadratic, centroid_1dg, centroid_2dg]) def test_centroid_hard_masked_array(centroid_func): """ Test that a MaskedArray with a hard mask gives the same result as one with a soft mask. """ data = make_gaussian_source((21, 21), 10.0, 10.3, 9.8, 2.0, 2.0, 0) data[4, 5] = 1000.0 mask = np.zeros(data.shape, dtype=bool) mask[4, 5] = True expected = centroid_func(data, mask=mask) soft = centroid_func(np.ma.MaskedArray(data, mask=mask)) hard = centroid_func(np.ma.MaskedArray(data, mask=mask, hard_mask=True)) assert_allclose(soft, expected) assert_allclose(hard, expected) 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(('xc', 'yc'), [(10.3, 9.6), (11.2, 10.0), (9.1, 11.4)]) @pytest.mark.parametrize('radius', [None, 3, 4.5]) @pytest.mark.parametrize('units', [True, False]) def test_centroid_symmetry(xc, yc, radius, units): """ Test centroid_symmetry with Gaussian data. """ data = make_gaussian_source((21, 21), 2.4, xc, yc, 2.0, 2.5, 0.3) if units: data = data * u.nJy xycen = centroid_symmetry(data, radius=radius) assert isinstance(xycen, np.ndarray) assert not isinstance(xycen, u.Quantity) assert_allclose(xycen, (xc, yc), atol=2.0e-3) def test_centroid_symmetry_small_array(): """ Test centroid_symmetry with the default radius on a 5 x 5 array. """ data = make_gaussian_source((5, 5), 1.0, 2.1, 1.9, 1.2, 1.2, 0) xycen = centroid_symmetry(data) assert_allclose(xycen, (2.1, 1.9), atol=0.01) @pytest.mark.parametrize(('shape', 'radius'), [((4, 4), None), ((10, 10), 4.2), ((6, 8), None), ((21, 9), None), ((9, 31), 3)]) def test_centroid_symmetry_shapes(shape, radius): """ Test centroid_symmetry with even and rectangular shapes. The search area of the first two cases lies between the pixel centers. """ xc = (shape[1] - 1) / 2 + 0.1 yc = (shape[0] - 1) / 2 - 0.1 data = make_gaussian_source(shape, 1.0, xc, yc, 1.2, 1.2, 0) xycen = centroid_symmetry(data, radius=radius) assert_allclose(xycen, (xc, yc), atol=0.02) @pytest.mark.parametrize('radius', [3, 4, 7]) @pytest.mark.parametrize(('std', 'atol'), [(0.6, 0.05), (0.8, 0.015)]) def test_centroid_symmetry_undersampled(radius, std, atol): """ Test the accuracy for an undersampled source with offsets spaced by half a pixel (a radius less than 6) and by one pixel. """ rng = np.random.default_rng(0) for _ in range(10): xc, yc = 10 + rng.uniform(-0.5, 0.5, 2) data = make_gaussian_source((21, 21), 1.0, xc, yc, std, std, 0) xycen = centroid_symmetry(data, radius=radius) assert_allclose(xycen, (xc, yc), atol=atol) @pytest.mark.parametrize('size', [101, 201, 401]) @pytest.mark.parametrize('noise', [False, True]) def test_centroid_symmetry_large_array(size, noise): """ Test that the search stays on a compact source in a large array. The featureless region around the source is also symmetric. """ xc = (size - 1) / 2 + 0.3 yc = (size - 1) / 2 - 0.2 data = make_gaussian_source((size, size), 100.0, xc, yc, 1.5, 1.5, 0) if noise: rng = np.random.default_rng(1) data += rng.normal(0, 1.0, data.shape) xycen = centroid_symmetry(data, radius=3) assert_allclose(xycen, (xc, yc), atol=0.02) def test_centroid_symmetry_search_area(): """ Test that the result is on the edge of the search area if the source lies outside of it. """ data = make_gaussian_source((101, 101), 1.0, 70.3, 30.6, 1.5, 1.5, 0) xycen = centroid_symmetry(data) assert_allclose(xycen, (69.7, 30.6), atol=1.0e-3) xycen = centroid_symmetry(data, radius=3) assert_allclose(xycen, (70.3, 30.6), atol=2.0e-3) @pytest.mark.parametrize(('size', 'fwhm'), [(5, 1.5), (10, 3.0), (11, 3.0), (11, 5.0), (21, 6.0), (21, 10.0)]) @pytest.mark.parametrize('factor', [1.5, 2.0, 3.0]) @pytest.mark.parametrize('direction', [(-1.0, -1.0), (1.0, 0.0), (0.0, -1.0), (1.0, -0.4)]) def test_centroid_symmetry_far_outside_search_area(size, fwhm, factor, direction): """ Test that the result is on the edge of the search area nearest to the source if the source lies far outside of it. A featureless region is also symmetric, so the asymmetry decreases away from a source that lies well beyond the search area. """ center = (size - 1) / 2 margin = center - 0.3 * size offset = factor * margin * np.array(direction) model = CircularGaussianPRF(flux=1.0, x_0=center + offset[0], y_0=center + offset[1], fwhm=fwhm) yy, xx = np.mgrid[:size, :size] data = model(xx, yy) xycen = centroid_symmetry(data) expected = center + np.clip(offset, -margin, margin) assert_allclose(xycen, expected, atol=0.05) def test_centroid_symmetry_far_outside_inputs(): """ Test a source far outside of the search area with noise, with masked values, and in an array that is not square. """ yy, xx = np.mgrid[:21, :21] model = CircularGaussianPRF(flux=1.0, x_0=0.75, y_0=0.75, fwhm=10.0) data = model(xx, yy) expected = (6.3, 6.3) rng = np.random.default_rng(0) noise = rng.normal(0.0, 0.01 * np.max(data), data.shape) xycen = centroid_symmetry(data + noise) assert_allclose(xycen, expected, atol=0.05) mask = np.zeros(data.shape, dtype=bool) mask[3:6, 3:6] = True xycen = centroid_symmetry(data, mask=mask) assert_allclose(xycen, expected, atol=0.05) # The search area is 4.5 to 9.5 in x and 4.5 to 25.5 in y yy, xx = np.mgrid[:31, :15] model = CircularGaussianPRF(flux=1.0, x_0=13.25, y_0=3.0, fwhm=4.0) xycen = centroid_symmetry(model(xx, yy)) assert_allclose(xycen, (9.5, 4.5), atol=0.05) @pytest.mark.parametrize('offset', [0.0, 0.2, 1.0, 2.0]) def test_centroid_symmetry_negative_data(offset): """ Test that the result for a source in the search area does not change if a constant is subtracted from the data, including one that makes all of the data values negative. """ core = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) lobe = make_gaussian_source((21, 21), 0.1, 14.5, 10.0, 1.5, 1.5, 0) data = core + lobe expected = centroid_symmetry(data, radius=3) xycen = centroid_symmetry(data - offset * np.max(data), radius=3) assert_allclose(xycen, expected, atol=1.0e-4) xycen = centroid_symmetry(data - offset * np.max(data)) assert_allclose(xycen, centroid_symmetry(data), atol=1.0e-4) @pytest.mark.parametrize('neighbor_flux', [0.5, 2.0, 5.0, 20.0]) def test_centroid_symmetry_search_area_neighbor(neighbor_flux): """ Test that the result stays on the edge of the search area nearest to a source just outside of it if there is a brighter source beyond the opposite edge. """ yy, xx = np.mgrid[:21, :21] source = CircularGaussianPRF(flux=1.0, x_0=5.9, y_0=10.3, fwhm=4.0) neighbor = CircularGaussianPRF(flux=neighbor_flux, x_0=19.5, y_0=9.0, fwhm=4.0) data = source(xx, yy) + neighbor(xx, yy) xycen = centroid_symmetry(data) assert_allclose(xycen, (6.3, 10.3), atol=0.05) @pytest.mark.parametrize(('size', 'fwhm'), [(10, 3.0), (11, 3.0), (11, 5.0), (21, 6.0), (21, 10.0)]) @pytest.mark.parametrize('inset', [0.02, 0.05, 0.1, 0.2]) @pytest.mark.parametrize('direction', [(-1.0, -1.0), (1.0, 0.3), (-0.4, 1.0)]) def test_centroid_symmetry_near_search_area_edge(size, fwhm, inset, direction): """ Test that the result is not on the edge of the search area if the source lies a fraction of a pixel inside of it. """ center = (size - 1) / 2 margin = center - 0.3 * size offset = (margin - inset) * np.array(direction) model = CircularGaussianPRF(flux=1.0, x_0=center + offset[0], y_0=center + offset[1], fwhm=fwhm) yy, xx = np.mgrid[:size, :size] data = model(xx, yy) xycen = centroid_symmetry(data) assert_allclose(xycen, center + offset, atol=0.006) def test_centroid_symmetry_centroid_sources(): """ Test centroid_symmetry as the centroid function of centroid_sources. """ data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) x, y = centroid_sources(data, (91, 151), (61, 24), centroid_func=centroid_symmetry) assert_allclose(x, (90, 150), atol=0.1) assert_allclose(y, (60, 25), atol=0.1) x2, y2 = centroid_sources(data, (91, 151), (61, 24), box_size=21, centroid_func=centroid_symmetry, radius=4) assert_allclose(x2, (90, 150), atol=0.1) assert_allclose(y2, (60, 25), atol=0.1) assert not np.array_equal(x2, x) def test_centroid_symmetry_asymmetric_source(): """ Test that centroid_symmetry is insensitive to structure beyond the radius, unlike centroid_com. """ core = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) lobe = make_gaussian_source((21, 21), 0.2, 16.0, 10.0, 1.5, 1.5, 0) data = core + lobe xycen = centroid_symmetry(data, radius=3) assert_allclose(xycen, (10.3, 9.6), atol=0.03) assert centroid_com(data)[0] - 10.3 > 0.3 @pytest.mark.parametrize(('index', 'atol'), [((9, 12), 3.0e-3), ((10, 10), 3.0e-3), ((slice(None), 12), 0.015)]) def test_centroid_symmetry_mask(index, atol): """ Test centroid_symmetry with a masked pixel near the center, a masked peak pixel, and a masked column near the center. The masked values have a large value to test that they have no effect on the result. """ data = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) mask = np.zeros(data.shape, dtype=bool) mask[index] = True data[mask] = 1000.0 xycen = centroid_symmetry(data, mask=mask) assert_allclose(xycen, (10.3, 9.6), atol=atol) @pytest.mark.parametrize(('std', 'atol'), [(2.0, 4.0e-3), (1.2, 0.04)]) def test_centroid_symmetry_mask_adjacent(std, atol): """ Test centroid_symmetry with a masked pixel adjacent to the peak pixel for different subpixel positions of the source. The asymmetry is a continuous function of the center, so the search does not stop at a position where the set of unmasked pairs changes. """ offsets = ((0, 1), (1, 0), (1, 1), (-1, 1), (0, -1), (-1, 0), (-1, -1), (1, -1)) rng = np.random.default_rng(0) for i in range(40): xc, yc = 10 + rng.uniform(-0.5, 0.5, 2) data = make_gaussian_source((21, 21), 1.0, xc, yc, std, std, 0) mask = np.zeros(data.shape, dtype=bool) dy, dx = offsets[i % 8] mask[round(yc) + dy, round(xc) + dx] = True xycen = centroid_symmetry(data, mask=mask) assert_allclose(xycen, (xc, yc), atol=atol) def test_centroid_symmetry_masked_array(): """ Test that a masked array gives the same result as a mask. """ data = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) mask = np.zeros(data.shape, dtype=bool) mask[9, 12] = True xycen = centroid_symmetry(data, mask=mask) xycen2 = centroid_symmetry(np.ma.masked_array(data, mask=mask)) assert_allclose(xycen2, xycen) def test_centroid_symmetry_hot_pixel(): """ Test that a bright outlier near the source must be masked. """ data = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) data[9, 16] = 3.0 xycen = centroid_symmetry(data, radius=3) assert_allclose(xycen, (16, 9), atol=0.05) xycen = centroid_symmetry(data, mask=(data == 3.0), radius=3) assert_allclose(xycen, (10.3, 9.6), atol=2.0e-3) def test_centroid_symmetry_nan(): """ Test that a nonfinite value gives the same result as a mask. """ data = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) mask = np.zeros(data.shape, dtype=bool) mask[9, 12] = True xycen = centroid_symmetry(data, mask=mask) data[9, 12] = np.nan match = 'Input data contains nonfinite values' with pytest.warns(AstropyUserWarning, match=match): xycen2 = centroid_symmetry(data) assert_allclose(xycen2, xycen) def test_centroid_symmetry_all_masked(): """ Test centroid_symmetry when no pair of values is unmasked. """ data = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) mask = np.ones(data.shape, dtype=bool) assert np.all(np.isnan(centroid_symmetry(data, mask=mask))) # Only two adjacent values are unmasked, so there are no pairs mask[10, 10:12] = False assert np.all(np.isnan(centroid_symmetry(data, mask=mask))) # The search area is masked mask = np.zeros(data.shape, dtype=bool) mask[5:16, 5:16] = True assert np.all(np.isnan(centroid_symmetry(data, mask=mask))) @pytest.mark.parametrize('value', [0.0, 1.0]) def test_centroid_symmetry_constant(value): """ Test centroid_symmetry with constant data, which are symmetric about every position. """ data = np.full((21, 21), value) assert np.all(np.isnan(centroid_symmetry(data))) mask = np.ones(data.shape, dtype=bool) mask[10, 10] = False assert np.all(np.isnan(centroid_symmetry(data, mask=mask))) @pytest.mark.parametrize('radius', [-1, 0.5, 5, 20, np.nan, np.inf]) def test_centroid_symmetry_invalid_radius(radius): """ Test centroid_symmetry with an invalid radius. """ data = np.ones((11, 11)) match = 'radius must be at least 1' with pytest.raises(ValueError, match=match): centroid_symmetry(data, radius=radius) @pytest.mark.parametrize('radius', ['3', np.array([3, 3]), 3 * u.pix, 3 + 0j]) def test_centroid_symmetry_invalid_radius_type(radius): """ Test centroid_symmetry with a radius that is not a real number. """ data = np.ones((11, 11)) match = 'radius must be a real number or None' with pytest.raises(TypeError, match=match): centroid_symmetry(data, radius=radius) def test_centroid_symmetry_invalid_inputs(): """ Test centroid_symmetry with invalid inputs. """ data = np.ones((11, 11)) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): centroid_symmetry(np.ones(11)) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): centroid_symmetry(data, mask=np.zeros((3, 3), dtype=bool)) @pytest.mark.parametrize('shape', [(3, 3), (3, 11), (0, 0)]) def test_centroid_symmetry_small_data(shape): """ Test centroid_symmetry with data that are too small. """ match = 'data must have at least 4 pixels along each axis' with pytest.raises(ValueError, match=match): centroid_symmetry(np.ones(shape)) def test_centroid_symmetry_mutation(): """ Test that centroid_symmetry does not mutate the input data or mask. """ data = make_gaussian_source((21, 21), 1.0, 10.3, 9.6, 2.0, 2.0, 0) mask = np.zeros(data.shape, dtype=bool) mask[2, 2] = True data_orig = data.copy() mask_orig = mask.copy() centroid_symmetry(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_mask(): """ Test centroid_quadratic with an extensive 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 not np.any(np.isnan(data)) assert_allclose(xycen, (xc_ref, yc_ref), atol=0.01) 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(PhotutilsDeprecationWarning): xycen2 = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=3) assert_allclose(xycen2, (5, 5)) with pytest.warns(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=15, ypeak=5) with (pytest.warns(PhotutilsDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=15, ypeak=15) match = 'ypeak is outside the input data' with (pytest.warns(PhotutilsDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=5, ypeak=15) def test_centroid_quadratic_large_coordinates(): """ Test for centroid_quadratic with a source at large pixel coordinates. The quadratic fit is performed in coordinates centered on the peak pixel. A design matrix built from absolute pixel coordinates becomes ill-conditioned at large coordinates (condition number ~coordinate**4) and previously returned NaN with a spurious "fit does not have a maximum" warning. """ xc_ref = 15.7 yc_ref = 15980.2 data = make_gaussian_source((16000, 31), 2.4, xc_ref, yc_ref, 2.5, 2.5, 0) xc, yc = centroid_quadratic(data) assert_allclose((xc, yc), (xc_ref, yc_ref), rtol=0, atol=0.03) 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 nonfinite 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 nonfinite 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 nonfinite 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_all_masked(): """ Test that centroid_quadratic raises a clear ValueError when all data values are masked or nonfinite. Previously, this raised a cryptic "All-NaN slice encountered" ValueError from numpy. """ data = np.ones((11, 11)) mask = np.ones(data.shape, dtype=bool) match = 'All data values are masked or nonfinite' with pytest.raises(ValueError, match=match): centroid_quadratic(data, mask=mask) data = np.full((11, 11), np.nan) warn_match = 'Input data contains nonfinite values' with (pytest.warns(AstropyUserWarning, match=warn_match), pytest.raises(ValueError, match=match)): centroid_quadratic(data) def test_centroid_quadratic_masked_array(): """ Test that a MaskedArray input to centroid_quadratic gives the same result as equivalent plain array and mask inputs. """ 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 = np.zeros(data.shape, dtype=bool) mask[25, 25] = True xc1, yc1 = centroid_quadratic(data, mask=mask) xc2, yc2 = centroid_quadratic(np.ma.array(data, mask=mask)) assert_allclose((xc1, yc1), (xc2, yc2)) assert_allclose((xc1, yc1), (xc_ref, yc_ref), atol=0.03) 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, fit_boxsize=3) 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(PhotutilsDeprecationWarning), pytest.raises(ValueError, match=match)): centroid_quadratic(data, xpeak=3, ypeak=None) with (pytest.warns(PhotutilsDeprecationWarning), 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_boxsize_clamp(): """ Test centroid_quadratic with a fit_boxsize larger than the data. The fit box is clamped to the data shape and reduced to odd values with a warning. The warning is emitted whether the data size is even or odd. """ data = make_gaussian_source((4, 4), 10.0, 2.0, 2.0, 1.0, 1.0, 0) match = "'fit_boxsize' was clamped to the upper bound and reduced to" with pytest.warns(AstropyUserWarning, match=match): xc, yc = centroid_quadratic(data, fit_boxsize=5) assert_allclose((xc, yc), (2.0, 2.0), atol=0.1) data = make_gaussian_source((5, 5), 10.0, 2.0, 2.0, 1.0, 1.0, 0) match = "'fit_boxsize' was clamped to the upper bound" with pytest.warns(AstropyUserWarning, match=match): xc, yc = centroid_quadratic(data, fit_boxsize=7) assert_allclose((xc, yc), (2.0, 2.0), atol=0.1) 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(PhotutilsDeprecationWarning): xycen = centroid_quadratic(data, xpeak=1, ypeak=1, fit_boxsize=5) assert_allclose(xycen, (0.923077, 0.923077)) with pytest.warns(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning), 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) @pytest.mark.parametrize( ('xpos', 'ypos', 'kwargs', 'match'), [([[25]], 26, {'box_size': 11}, 'xpos must be a 1D array'), (25, [[26]], {'box_size': 11}, 'ypos must be a 1D array'), ([25, 26], [26], {'box_size': 11}, 'xpos and ypos must have the same length'), (25, 26, {'box_size': (1, 2, 3)}, 'box_size must have 1 or 2 elements'), (25, 26, {'box_size': None, 'footprint': None}, 'box_size or footprint must be defined'), (25, 26, {'footprint': np.ones((3, 3, 3))}, 'footprint must be a 2D array')]) def test_invalid_inputs(self, xpos, ypos, kwargs, match): """ Test centroid_sources with invalid inputs. """ data = np.ones((50, 47)) with pytest.raises(ValueError, match=match): centroid_sources(data, xpos, ypos, **kwargs) def test_invalid_data(self): """ Test centroid_sources with non-2D data. """ 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) def test_centroid_func_no_mask_keyword(self): """ Test centroid_sources with a centroid function that has no mask keyword. """ def test_func(data): return data data = np.ones((50, 47)) 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) @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) # The last source is at the data edge, so its 2x2 cutout # clamps the fit_boxsize (with a warning) to a size that is # too small for the quadratic fit match = 'Centroid failed for source' clamp_match = "'fit_boxsize' was clamped" with (pytest.warns(AstropyUserWarning, match=match), pytest.warns(AstropyUserWarning, match=clamp_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_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(PhotutilsDeprecationWarning): 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_unknown_kwargs(self): """ Test that centroid_sources raises a TypeError for keyword arguments not accepted by the centroid function. """ data = np.ones((50, 50)) match = 'Unrecognized keyword argument\\(s\\) for the input' with pytest.raises(TypeError, match=match): centroid_sources(data, 25, 25, box_size=11, centroid_func=centroid_quadratic, fit_boxsizes=3) # The error keyword is not accepted by centroid_com with pytest.raises(TypeError, match=match): centroid_sources(data, 25, 25, box_size=11, error=np.ones(data.shape)) def test_error_wrong_shape(self): """ Test that centroid_sources raises a ValueError when the error shape does not match the data shape. """ data = np.ones((50, 50)) match = 'error and data must have the same shape' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 25, box_size=11, centroid_func=centroid_1dg, error=np.ones((5, 5))) def test_var_keyword_centroid_func(self, test_data): """ Test centroid_sources with a centroid function that accepts the mask keyword via **kwargs. """ def my_centroid(data, **kwargs): return centroid_com(data, mask=kwargs.get('mask')) data, xpos, ypos = test_data xcen1, ycen1 = centroid_sources(data, xpos[1], ypos[1], box_size=11, centroid_func=my_centroid) xcen2, ycen2 = centroid_sources(data, xpos[1], ypos[1], box_size=11, centroid_func=centroid_com) assert_allclose(xcen1, xcen2) assert_allclose(ycen1, ycen2) def test_nonfinite_positions(self, test_data): """ Test that centroid_sources raises a ValueError for nonfinite xpos or ypos values. """ data = test_data[0] match = 'xpos and ypos must contain only finite values' with pytest.raises(ValueError, match=match): centroid_sources(data, np.nan, 25, box_size=11) with pytest.raises(ValueError, match=match): centroid_sources(data, 25, np.inf, box_size=11) 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 multisource 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning): 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 = 'footprint must contain at least one True value' 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) match = 'is completely masked' 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-24be529/photutils/centroids/tests/test_gaussian.py000066400000000000000000000255611526452531300262330ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the gaussian 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 Gaussian1D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_array_equal from photutils.centroids._utils import _gaussian1d_moments, _gaussian2d_moments from photutils.centroids.gaussian import centroid_1dg, centroid_2dg from photutils.centroids.tests.helpers import make_gaussian_source @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 n_warn = 0 ctx = nullcontext() else: mask = None n_warn = 1 match = 'Input data contains nonfinite 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 n_warn == 1: assert len(warnlist) == n_warn 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 n_warn == 1: assert len(warnlist) == n_warn 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 at 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. A clear error is raised rather than silent NaN results. """ data = np.full((10, 10), value) match = 'Input data must have nonconstant values' 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_masked(value): """ Test that centroid_2dg raises a ValueError if the unmasked data are constant. The masked pixels are set to zero internally, so they must not make constant data appear to vary. """ data = np.full((10, 10), value) data[2, 3] = 100.0 mask = np.zeros(data.shape, dtype=bool) mask[2, 3] = True match = 'Input data must have nonconstant values' with pytest.raises(ValueError, match=match): centroid_2dg(data, mask=mask) data[2, 3] = np.nan with (pytest.warns(AstropyUserWarning, match='nonfinite values'), pytest.raises(ValueError, match=match)): centroid_2dg(data) def test_centroid_2dg_initial_guess_masked(): """ Test that masked pixels do not contribute to the initial parameter estimates of centroid_2dg when the data minimum is negative. The minimum of the unmasked data is subtracted before the moments are calculated, and the masked pixels must remain zero. """ data = make_gaussian_source((31, 31), 100.0, 12.3, 17.6, 2.0, 2.0, 0) data -= 5.0 mask = np.zeros(data.shape, dtype=bool) mask[26:28, :10] = True data[mask] = 1000.0 target = 'photutils.centroids.gaussian._gaussian2d_moments' with patch(target, wraps=_gaussian2d_moments) as mock_moments: xycen = centroid_2dg(data, mask=mask) shifted = mock_moments.call_args.args[0] assert_array_equal(shifted[mask], 0.0) assert np.min(shifted[~mask]) == 0.0 assert_allclose(_gaussian2d_moments(shifted)[1:3], (12.3, 17.6), atol=0.01) assert_allclose(xycen, (12.3, 17.6), atol=1.0e-4) @pytest.mark.parametrize('value', [0.0, 1.0, -3.7]) def test_centroid_1dg_constant_data(value): """ Test that centroid_1dg raises a ValueError for constant (flat) input data. A constant marginal distribution has undefined Gaussian moment estimates. This previously raised a cryptic error from inside the fitter. """ data = np.full((10, 10), value) match = 'Input data must have a nonzero sum and nonconstant values' with pytest.raises(ValueError, match=match): centroid_1dg(data) def test_centroid_1dg_zero_sum_data(): """ Test that centroid_1dg raises a ValueError for nonconstant data whose sum is zero. A zero-sum marginal distribution makes the moment estimates 0/0. """ data = np.zeros((10, 10)) data[3, 3] = 5.0 data[7, 7] = -5.0 match = 'Input data must have a nonzero sum and nonconstant values' with pytest.raises(ValueError, match=match): centroid_1dg(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, dtype=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, dtype=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, dtype=bool) mask[0] = False match = 'Input data contains nonfinite 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 emits only the warning of the fitter if the fit did not converge. """ data = make_gaussian_source((51, 51), 1.0, 24.17, 25.87, 1.7, 4.7, 0.0) match = 'The fit may be unsuccessful' with pytest.warns(AstropyUserWarning, match=match) as record: centroid_2dg(data + 100000) assert len(record) == 1 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-24be529/photutils/centroids/tests/test_thread_safety.py000066400000000000000000000142611526452531300272360ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the centroid functions are thread-safe and return identical results when run concurrently from multiple threads. The centroid functions hold no shared mutable state. There are no module-level caches, each call creates its own fitter instance, and fit nonconvergence is detected from the fitter's ``fit_info`` rather than by capturing warnings (``warnings.catch_warnings`` mutates process-global state). The convergence-warning test turns warnings into errors itself (rather than relying on the pytest ``filterwarnings`` configuration, which is absent when pytest runs with ``-p no:warnings``), so any spurious warning raised in a worker thread (e.g., a cross-thread "fit may not have converged" injection) fails the corresponding future. """ import warnings from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.centroids import (centroid_1dg, centroid_2dg, centroid_com, centroid_quadratic, centroid_sources) from photutils.centroids.tests.helpers import make_gaussian_source N_THREADS = 8 N_CALLS_PER_THREAD = 4 CENTROID_FUNCS = [centroid_com, centroid_quadratic, centroid_1dg, centroid_2dg] # Centroid functions that accept an error keyword ERROR_FUNCS = (centroid_1dg, centroid_2dg) @pytest.fixture(name='source_data') def fixture_source_data(): """ Create a single-source cutout with a mask and an error array. """ data = make_gaussian_source((51, 51), 10.0, 24.7, 25.2, 3.0, 3.0, 0) mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True error = np.sqrt(np.abs(data)) return data, mask, error def _make_task(func, data, mask, error): """ Return a zero-argument callable that computes the centroid of the data with the given centroid function. """ kwargs = {'mask': mask} if func in ERROR_FUNCS: kwargs['error'] = error def task(): return func(data, **kwargs) return task @pytest.mark.parametrize('centroid_func', CENTROID_FUNCS) def test_concurrent_centroid_calls(source_data, centroid_func): """ Test that concurrent calls to each centroid function on shared input arrays return results identical to a serial call. """ task = _make_task(centroid_func, *source_data) expected = task() with ThreadPoolExecutor(max_workers=N_THREADS) as executor: futures = [executor.submit(task) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] for future in futures: assert_array_equal(future.result(), expected) def test_mixed_concurrent_centroid_calls(source_data): """ Test all of the centroid functions running interleaved in one thread pool. Mixing the functions surfaces cross-call interference (e.g., the previous warning-capture convergence detection in centroid_2dg could suppress another thread's warnings or inject a spurious convergence warning into it). """ tasks = {func.__name__: _make_task(func, *source_data) for func in CENTROID_FUNCS} expected = {name: task() for name, task in tasks.items()} with ThreadPoolExecutor(max_workers=N_THREADS) as executor: futures = [(name, executor.submit(task)) for name, task in tasks.items() for _ in range(N_THREADS)] for name, future in futures: assert_array_equal(future.result(), expected[name]) def test_concurrent_convergence_warning_isolation(): """ Test that converging centroid_2dg fits stay warning-free while nonconverging fits warn concurrently in other threads. With the previous warning-capture convergence detection, a nonconverging fit in one thread could inject its warning into a converging fit in another thread (or have its own warning suppressed). Warnings are turned into errors for the duration of the thread pool (in the main thread, so that all worker threads see the same filter), so an injected warning would fail the converging futures below and a missing warning would fail the nonconverging futures. """ good_data = make_gaussian_source((51, 51), 10.0, 24.7, 25.2, 3.0, 3.0, 0) # The large offset makes the fit hit the maximum number of function # evaluations without converging bad_data = good_data + 100000.0 def good_task(): return centroid_2dg(good_data) def bad_task(): try: centroid_2dg(bad_data) except Warning as exc: return str(exc) return 'no warning' expected = good_task() with (warnings.catch_warnings(), ThreadPoolExecutor(max_workers=N_THREADS) as executor): warnings.simplefilter('error') good_futures = [executor.submit(good_task) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] bad_futures = [executor.submit(bad_task) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] for future in good_futures: assert_array_equal(future.result(), expected) for future in bad_futures: assert 'fit may' in future.result() def test_concurrent_centroid_sources(): """ Test that concurrent centroid_sources calls on a shared image return results identical to a serial call. """ xpos = [25.0, 75.0] ypos = [30.0, 70.0] data = (make_gaussian_source((100, 100), 10.0, xpos[0], ypos[0], 4.0, 4.0, 0) + make_gaussian_source((100, 100), 10.0, xpos[1], ypos[1], 4.0, 4.0, 0)) error = np.ones(data.shape) def task(): return centroid_sources(data, xpos, ypos, box_size=21, centroid_func=centroid_2dg, error=error) expected = task() with ThreadPoolExecutor(max_workers=N_THREADS) as executor: futures = [executor.submit(task) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] for future in futures: xcen, ycen = future.result() assert_array_equal(xcen, expected[0]) assert_array_equal(ycen, expected[1]) astropy-photutils-24be529/photutils/centroids/tests/test_utils.py000066400000000000000000000271051526452531300255550ustar00rootroot00000000000000# 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 nonfinite 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 @pytest.mark.parametrize('hard_mask', [False, True]) def test_masked_array_hard_mask(self, hard_mask): """ Masked values are filled whether the mask is soft or hard. Assignment to a masked element of a hard-masked array is silently ignored, so the values must be filled in the plain data array. """ masked_data = np.ma.array([[1.0, 2.0], [3.0, 4.0]], mask=[[False, True], [False, False]], hard_mask=hard_mask) extra_mask = np.array([[True, False], [False, False]]) result = _process_data_mask(masked_data, extra_mask, fill_value=0.0) assert_array_equal(result, [[0.0, 0.0], [3.0, 4.0]]) # The input is not mutated assert_array_equal(masked_data.data, [[1.0, 2.0], [3.0, 4.0]]) assert_array_equal(masked_data.mask, [[False, True], [False, False]]) 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 nonfinite 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): """ Test that a circular Gaussian recovers the centroid and equal stddevs. """ 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): """ Test that an axis-aligned elliptical Gaussian (theta=0) recovers the centroid and axis stddevs. 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-24be529/photutils/conftest.py000066400000000000000000000022771526452531300220520ustar00rootroot00000000000000# 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-24be529/photutils/converters/000077500000000000000000000000001526452531300220355ustar00rootroot00000000000000astropy-photutils-24be529/photutils/converters/__init__.py000066400000000000000000000046631526452531300241570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ ASDF converters. The converters are defined only when the optional dependencies they need are installed. The aperture converters need only ``asdf``. The PSF converters are built on the ``asdf-astropy`` transform machinery, so they also need ``asdf-astropy``. """ from importlib.util import find_spec _ASDF_INSTALLED = find_spec('asdf') is not None # Because asdf-astropy imports asdf, it is unusable without it. _ASDF_ASTROPY_INSTALLED = (_ASDF_INSTALLED and find_spec('asdf_astropy') is not None) __all__ = [] if _ASDF_INSTALLED: from .apertures import (CircularAnnulusConverter, CircularApertureConverter, EllipticalAnnulusConverter, EllipticalApertureConverter, PolygonApertureConverter, RectangularAnnulusConverter, RectangularApertureConverter) __all__ += ['CircularAnnulusConverter', 'CircularApertureConverter', 'EllipticalAnnulusConverter', 'EllipticalApertureConverter', 'PolygonApertureConverter', 'RectangularAnnulusConverter', 'RectangularApertureConverter', ] if _ASDF_ASTROPY_INSTALLED: from .functional_models import (AiryDiskPRFConverter, AiryDiskPSFConverter, CircularGaussianPRFConverter, CircularGaussianPSFConverter, CircularGaussianSigmaPRFConverter, GaussianPRFConverter, GaussianPSFConverter, MoffatPRFConverter, MoffatPSFConverter) from .image_models import (GriddedPSFModelConverter, ImagePSFConverter, STDPSFGridConverter) __all__ += ['AiryDiskPRFConverter', 'AiryDiskPSFConverter', 'CircularGaussianPRFConverter', 'CircularGaussianPSFConverter', 'CircularGaussianSigmaPRFConverter', 'GaussianPRFConverter', 'GaussianPSFConverter', 'GriddedPSFModelConverter', 'ImagePSFConverter', 'MoffatPRFConverter', 'MoffatPSFConverter', 'STDPSFGridConverter', ] astropy-photutils-24be529/photutils/converters/_utils.py000066400000000000000000000031531526452531300237100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Helpers shared by the photutils ASDF converters. """ def optional_params(node, *names): """ Return the optional parameters present in a YAML tree as a keyword dictionary. A parameter that is absent from ``node`` is omitted from the result instead of being returned as `None`, so that the class supplies its own default. This is required because the defaults are not always `None` and may differ between related classes (e.g., the ``theta`` default differs between the pixel and sky apertures). Parameters ---------- node : dict The YAML tree of a tagged object. *names : str The names of the optional parameters. Returns ------- params : dict The subset of ``names`` that is present in ``node``, mapped to the corresponding values. """ return {name: node[name] for name in names if name in node} def add_parameter_bounds(node, model): """ Store every parameter bound of a model in its YAML tree. The ``asdf-astropy`` transform converter omits a bound whose limits are all zero or `None` (e.g., a nonnegative bound of ``(0, None)``), so such a bound would be lost on a round-trip. Parameters ---------- node : dict The YAML tree of the model, which is modified in place. model : `astropy.modeling.Model` The model being serialized. """ bounds = {name: bound for name, bound in model.bounds.items() if any(limit is not None for limit in bound)} if bounds: node['bounds'] = bounds astropy-photutils-24be529/photutils/converters/apertures.py000066400000000000000000000117561526452531300244330ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Converters to and from the ASDF data format for photutils apertures. """ from asdf.extension import Converter from astropy.coordinates import SkyCoord from photutils.converters._utils import optional_params __all__ = ['CircularAnnulusConverter', 'CircularApertureConverter', 'EllipticalAnnulusConverter', 'EllipticalApertureConverter', 'PolygonApertureConverter', 'RectangularAnnulusConverter', 'RectangularApertureConverter', ] class _ApertureConverter(Converter): """ Base class for the photutils aperture converters. The pixel and sky variants of an aperture share a tag, so a single converter handles both. Subclasses list the pixel class first and the sky class second in ``types``, and name the aperture parameters that accompany ``positions``. Every parameter is written to the file. Those in ``optional_aperture_params`` may be absent when reading, in which case the aperture class supplies its default. They are omitted rather than passed as `None` because the defaults are not always `None` and differ between the pixel and sky apertures. """ aperture_params = () optional_aperture_params = () def _aperture_class(self, positions): """ Return the pixel or sky aperture class for ``positions``. """ from photutils import aperture index = 1 if isinstance(positions, SkyCoord) else 0 return getattr(aperture, self.types[index].rsplit('.', 1)[-1]) def to_yaml_tree(self, obj, tag, ctx): # noqa: ARG002 positions = obj.positions if not isinstance(positions, SkyCoord) and positions.shape == (2,): # Store a single pixel position as a plain (x, y) pair positions = positions.tolist() node = {'positions': positions} for name in (*self.aperture_params, *self.optional_aperture_params): node[name] = getattr(obj, name) return node def from_yaml_tree(self, node, tag, ctx): # noqa: ARG002 positions = node['positions'] params = {name: node[name] for name in self.aperture_params} return self._aperture_class(positions)( positions=positions, **params, **optional_params(node, *self.optional_aperture_params), ) class CircularApertureConverter(_ApertureConverter): """ ASDF converter for circular apertures. """ tags = ('tag:astropy.org:photutils/aperture/circular_aperture-*',) types = ('photutils.aperture.CircularAperture', 'photutils.aperture.SkyCircularAperture', ) aperture_params = ('r',) class CircularAnnulusConverter(_ApertureConverter): """ ASDF converter for circular annulus apertures. """ tags = ('tag:astropy.org:photutils/aperture/circular_annulus-*',) types = ('photutils.aperture.CircularAnnulus', 'photutils.aperture.SkyCircularAnnulus', ) aperture_params = ('r_in', 'r_out') class EllipticalApertureConverter(_ApertureConverter): """ ASDF converter for elliptical apertures. """ tags = ('tag:astropy.org:photutils/aperture/elliptical_aperture-*',) types = ('photutils.aperture.EllipticalAperture', 'photutils.aperture.SkyEllipticalAperture', ) aperture_params = ('a', 'b') optional_aperture_params = ('theta',) class EllipticalAnnulusConverter(_ApertureConverter): """ ASDF converter for elliptical annulus apertures. """ tags = ('tag:astropy.org:photutils/aperture/elliptical_annulus-*',) types = ('photutils.aperture.EllipticalAnnulus', 'photutils.aperture.SkyEllipticalAnnulus', ) aperture_params = ('a_in', 'a_out', 'b_out') optional_aperture_params = ('b_in', 'theta') class PolygonApertureConverter(_ApertureConverter): """ ASDF converter for polygon apertures. """ tags = ('tag:astropy.org:photutils/aperture/polygon_aperture-*',) types = ('photutils.aperture.PolygonAperture', 'photutils.aperture.SkyPolygonAperture', ) aperture_params = ('vertex_offsets',) class RectangularApertureConverter(_ApertureConverter): """ ASDF converter for rectangular apertures. """ tags = ('tag:astropy.org:photutils/aperture/rectangular_aperture-*',) types = ('photutils.aperture.RectangularAperture', 'photutils.aperture.SkyRectangularAperture', ) aperture_params = ('w', 'h') optional_aperture_params = ('theta',) class RectangularAnnulusConverter(_ApertureConverter): """ ASDF converter for rectangular annulus apertures. """ tags = ('tag:astropy.org:photutils/aperture/rectangular_annulus-*',) types = ('photutils.aperture.RectangularAnnulus', 'photutils.aperture.SkyRectangularAnnulus', ) aperture_params = ('w_in', 'w_out', 'h_out') optional_aperture_params = ('h_in', 'theta') astropy-photutils-24be529/photutils/converters/functional_models.py000066400000000000000000000127611526452531300261230ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Converters to and from the ASDF format for photutils.psf.functional_models. """ from asdf_astropy.converters.transform.core import (TransformConverterBase, parameter_to_value) from photutils.converters._utils import add_parameter_bounds, optional_params __all__ = ['AiryDiskPRFConverter', 'AiryDiskPSFConverter', 'CircularGaussianPRFConverter', 'CircularGaussianPSFConverter', 'CircularGaussianSigmaPRFConverter', 'GaussianPRFConverter', 'GaussianPSFConverter', 'MoffatPRFConverter', 'MoffatPSFConverter', ] class _PSFModelConverter(TransformConverterBase): """ Base class for the photutils PSF model converters. Subclasses name the model parameters that describe the profile. ``flux``, ``x_0``, ``y_0``, and ``bbox_factor`` are common to every model and are handled here. Every parameter is written to the file. Those in ``optional_model_params``, and ``bbox_factor``, may be absent when reading, in which case the model class supplies its default. The same holds for the model attributes in ``optional_attributes``, which are not model parameters. """ model_params = () optional_model_params = () optional_attributes = () def _model_class(self): """ Return the model class handled by this converter. """ from photutils import psf return getattr(psf, self.types[0].rsplit('.', 1)[-1]) def to_yaml_tree(self, obj, tag, ctx): node = super().to_yaml_tree(obj, tag, ctx) add_parameter_bounds(node, obj) return node def to_yaml_tree_transform(self, model, tag, ctx): # noqa: ARG002 names = ('flux', 'x_0', 'y_0', *self.model_params, *self.optional_model_params) node = {name: parameter_to_value(getattr(model, name)) for name in names} node['bbox_factor'] = model.bbox_factor for name in self.optional_attributes: node[name] = getattr(model, name) return node def from_yaml_tree_transform(self, node, tag, ctx): # noqa: ARG002 names = ('flux', 'x_0', 'y_0', *self.model_params) params = {name: node[name] for name in names} model = self._model_class()( **params, **optional_params(node, *self.optional_model_params, 'bbox_factor', *self.optional_attributes), ) # The file stores only the fixed=True entries and the bounds # that are set, relative to a baseline of fixed=False and bounds # of (None, None). The photutils models default their shape # parameters to fixed=True with bounds set, so the constraints # are reset to that baseline here. The caller then applies the # stored constraints on top, which reconstructs the saved state # exactly. for name in model.param_names: model.fixed[name] = False model.bounds[name] = (None, None) return model class AiryDiskPRFConverter(_PSFModelConverter): """ ASDF converter for AiryDiskPRF. """ tags = ('tag:astropy.org:photutils/psf/airy_disk_prf-*',) types = ('photutils.psf.AiryDiskPRF',) model_params = ('radius',) optional_attributes = ('n_nodes',) class AiryDiskPSFConverter(_PSFModelConverter): """ ASDF converter for AiryDiskPSF. """ tags = ('tag:astropy.org:photutils/psf/airy_disk_psf-*',) types = ('photutils.psf.AiryDiskPSF',) model_params = ('radius',) class CircularGaussianPRFConverter(_PSFModelConverter): """ ASDF converter for CircularGaussianPRF. """ tags = ('tag:astropy.org:photutils/psf/circular_gaussian_prf-*',) types = ('photutils.psf.CircularGaussianPRF',) model_params = ('fwhm',) class CircularGaussianPSFConverter(_PSFModelConverter): """ ASDF converter for CircularGaussianPSF. """ tags = ('tag:astropy.org:photutils/psf/circular_gaussian_psf-*',) types = ('photutils.psf.CircularGaussianPSF',) model_params = ('fwhm',) class CircularGaussianSigmaPRFConverter(_PSFModelConverter): """ ASDF converter for CircularGaussianSigmaPRF. """ tags = ('tag:astropy.org:photutils/psf/circular_gaussian_sigma_prf-*',) types = ('photutils.psf.CircularGaussianSigmaPRF',) model_params = ('sigma',) class GaussianPRFConverter(_PSFModelConverter): """ ASDF converter for GaussianPRF. """ tags = ('tag:astropy.org:photutils/psf/gaussian_prf-*',) types = ('photutils.psf.GaussianPRF',) model_params = ('x_fwhm', 'y_fwhm') optional_model_params = ('theta',) class GaussianPSFConverter(_PSFModelConverter): """ ASDF converter for GaussianPSF. """ tags = ('tag:astropy.org:photutils/psf/gaussian_psf-*',) types = ('photutils.psf.GaussianPSF',) model_params = ('x_fwhm', 'y_fwhm') optional_model_params = ('theta',) class MoffatPRFConverter(_PSFModelConverter): """ ASDF converter for MoffatPRF. """ tags = ('tag:astropy.org:photutils/psf/moffat_prf-*',) types = ('photutils.psf.MoffatPRF',) model_params = ('alpha', 'beta') optional_attributes = ('n_nodes',) class MoffatPSFConverter(_PSFModelConverter): """ ASDF converter for MoffatPSF. """ tags = ('tag:astropy.org:photutils/psf/moffat_psf-*',) types = ('photutils.psf.MoffatPSF',) model_params = ('alpha', 'beta') astropy-photutils-24be529/photutils/converters/image_models.py000066400000000000000000000117761526452531300250500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Converters to and from the ASDF format for photutils.psf.image_models and photutils.psf.gridded_models. """ import numpy as np from asdf.extension import Converter from asdf_astropy.converters.transform.core import (TransformConverterBase, parameter_to_value) from photutils.converters._utils import add_parameter_bounds, optional_params __all__ = ['GriddedPSFModelConverter', 'ImagePSFConverter', 'STDPSFGridConverter', ] class ImagePSFConverter(TransformConverterBase): """ ASDF converter for ImagePSF. """ tags = ('tag:astropy.org:photutils/psf/image_psf-*',) types = ('photutils.psf.ImagePSF',) def to_yaml_tree(self, obj, tag, ctx): node = super().to_yaml_tree(obj, tag, ctx) add_parameter_bounds(node, obj) return node def to_yaml_tree_transform(self, model, tag, ctx): # noqa: ARG002 return { 'data': model.data, 'flux': parameter_to_value(model.flux), 'x_0': parameter_to_value(model.x_0), 'y_0': parameter_to_value(model.y_0), 'oversampling': model.oversampling, 'fill_value': model.fill_value, 'origin': model.origin, } def from_yaml_tree_transform(self, node, tag, ctx): # noqa: ARG002 from photutils.psf import ImagePSF return ImagePSF( # Wrapping "data" as an ndarray to ensure the data is # properly converted from NDArrayType. This is necessary # because the validation in ImagePSF happens before "data" # is assigned. data=np.array(node['data']), **optional_params(node, 'flux', 'x_0', 'y_0', 'oversampling', 'fill_value', 'origin'), ) class GriddedPSFModelConverter(TransformConverterBase): """ ASDF converter for GriddedPSFModel. """ tags = ('tag:astropy.org:photutils/psf/gridded_psf_model-*',) types = ('photutils.psf.GriddedPSFModel',) def to_yaml_tree(self, obj, tag, ctx): node = super().to_yaml_tree(obj, tag, ctx) add_parameter_bounds(node, obj) return node def to_yaml_tree_transform(self, model, tag, ctx): # noqa: ARG002 node = { 'data': model.data, 'flux': parameter_to_value(model.flux), 'x_0': parameter_to_value(model.x_0), 'y_0': parameter_to_value(model.y_0), 'oversampling': model.oversampling, 'fill_value': model.fill_value, 'grid_xypos': model.grid_xypos, } # Preserve any additional meta items (e.g., 'STDPSF', 'detector', # 'filter'). 'grid_xypos' and 'oversampling' are stored above. node['meta'] = { key: value for key, value in model.meta.items() if key not in ('grid_xypos', 'oversampling') } return node def from_yaml_tree_transform(self, node, tag, ctx): # noqa: ARG002 from astropy.nddata import NDData from photutils.psf import GriddedPSFModel meta = dict(node['meta']) meta['grid_xypos'] = node['grid_xypos'] meta['oversampling'] = node['oversampling'] nd_data = NDData(data=np.array(node['data']), meta=meta) return GriddedPSFModel( nddata=nd_data, **optional_params(node, 'flux', 'x_0', 'y_0', 'fill_value'), ) class STDPSFGridConverter(Converter): """ ASDF converter for STDPSFGrid. """ tags = ('tag:astropy.org:photutils/psf/stdpsf_grid-*',) types = ('photutils.psf.STDPSFGrid',) def to_yaml_tree(self, obj, tag, ctx): # noqa: ARG002 # The grid structure is stored as separate properties, as it is # for GriddedPSFModel, leaving 'meta' for the remaining metadata # (e.g., 'STDPSF', 'detector', 'filter'). STDPSFGrid consumes # these three items when it is initialized, so they never appear # in its meta attribute. return { 'data': obj.data, 'grid_xypos': np.asarray(obj.grid_xypos), 'grid_shape': obj.grid_shape, 'oversampling': tuple(int(value) for value in obj.oversampling), 'meta': dict(obj.meta), } def from_yaml_tree(self, node, tag, ctx): # noqa: ARG002 from photutils.psf import STDPSFGrid meta = dict(node['meta']) # Load the lazily-read grid positions into memory meta['grid_xypos'] = np.asarray(node['grid_xypos']) meta['grid_shape'] = node['grid_shape'] if 'oversampling' in node: # The schema does not require this key. When it is absent # the class supplies its default. meta['oversampling'] = node['oversampling'] # Load the lazily-read data into memory so that the returned # object remains usable after the file is closed return STDPSFGrid._from_asdf(np.asarray(node['data']), meta) astropy-photutils-24be529/photutils/converters/tests/000077500000000000000000000000001526452531300231775ustar00rootroot00000000000000astropy-photutils-24be529/photutils/converters/tests/__init__.py000066400000000000000000000000001526452531300252760ustar00rootroot00000000000000astropy-photutils-24be529/photutils/converters/tests/examples.py000066400000000000000000000342121526452531300253710ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Examples for testing the photutils ASDF converters. Each example returns a tuple containing a photutils object and the list of parameter names to compare in round-trip tests. """ from pathlib import Path import numpy as np from astropy import units as u from astropy.coordinates import Angle, SkyCoord from astropy.nddata import NDData from photutils import aperture from photutils.psf import (AiryDiskPRF, AiryDiskPSF, CircularGaussianPRF, CircularGaussianPSF, CircularGaussianSigmaPRF, GaussianPRF, GaussianPSF, GriddedPSFModel, ImagePSF, MoffatPRF, MoffatPSF, STDPSFGrid) # The directory holding the STDPSF test files PSF_DATA_DIR = Path(__file__).resolve().parents[2] / 'psf' / 'tests' / 'data' parameters = { 'AiryDiskPRF': ['flux', 'x_0', 'y_0', 'radius', 'bbox_factor', 'n_nodes'], 'AiryDiskPSF': ['flux', 'x_0', 'y_0', 'radius', 'bbox_factor'], 'CircularGaussianPRF': ['flux', 'x_0', 'y_0', 'fwhm', 'bbox_factor'], 'CircularGaussianPSF': ['flux', 'x_0', 'y_0', 'fwhm', 'bbox_factor'], 'CircularGaussianSigmaPRF': ['flux', 'x_0', 'y_0', 'sigma', 'bbox_factor'], 'GaussianPRF': ['flux', 'x_0', 'y_0', 'x_fwhm', 'y_fwhm', 'theta', 'bbox_factor'], 'GaussianPSF': ['flux', 'x_0', 'y_0', 'x_fwhm', 'y_fwhm', 'theta', 'bbox_factor'], 'MoffatPRF': ['flux', 'x_0', 'y_0', 'alpha', 'beta', 'bbox_factor', 'n_nodes'], 'MoffatPSF': ['flux', 'x_0', 'y_0', 'alpha', 'beta', 'bbox_factor'], 'ImagePSF': ['data', 'flux', 'x_0', 'y_0', 'oversampling', 'fill_value', 'origin'], 'GriddedPSFModel': ['data', 'flux', 'x_0', 'y_0', 'oversampling', 'fill_value', 'grid_xypos'], 'STDPSFGrid': ['data', 'grid_xypos', 'oversampling'], 'CircularAperture': ['positions', 'r'], 'CircularAnnulus': ['positions', 'r_in', 'r_out'], 'EllipticalAperture': ['positions', 'a', 'b', 'theta'], 'EllipticalAnnulus': ['positions', 'a_in', 'a_out', 'b_in', 'b_out', 'theta'], 'PolygonAperture': ['positions', 'vertex_offsets'], 'RectangularAperture': ['positions', 'w', 'h', 'theta'], 'RectangularAnnulus': ['positions', 'w_in', 'w_out', 'h_in', 'h_out', 'theta'], } def airy_disk_units(): """ Return an AiryDiskPSF with units. """ return (AiryDiskPSF(flux=1 * u.Jy, x_0=0 * u.arcsec, y_0=0 * u.arcsec, radius=1 * u.arcsec, bbox_factor=2), parameters['AiryDiskPSF']) def airy_disk(): """ Return an AiryDiskPSF without units. """ return (AiryDiskPSF(flux=2, x_0=1, y_0=1, radius=2, bbox_factor=3), parameters['AiryDiskPSF']) def circular_gaussian_prf_units(): """ Return a CircularGaussianPRF with units. """ return (CircularGaussianPRF(flux=1 * u.Jy, x_0=0 * u.arcsec, y_0=0 * u.arcsec, fwhm=1 * u.arcsec, bbox_factor=2), parameters['CircularGaussianPRF']) def circular_gaussian_prf(): """ Return a CircularGaussianPRF without units. """ return (CircularGaussianPRF(flux=2, x_0=1, y_0=1, fwhm=2, bbox_factor=3), parameters['CircularGaussianPRF']) def circular_gaussian_sigma_prf_units(): """ Return a CircularGaussianSigmaPRF with units. """ return (CircularGaussianSigmaPRF(flux=71.4 * u.Jy, x_0=24.3 * u.pix, y_0=25.2 * u.pix, sigma=5.1 * u.pix), parameters['CircularGaussianSigmaPRF']) def circular_gaussian_sigma_prf(): """ Return a CircularGaussianSigmaPRF without units. """ return (CircularGaussianSigmaPRF(flux=71.4, x_0=24.3, y_0=25.2, sigma=5.1), parameters['CircularGaussianSigmaPRF']) def circular_gaussian_psf_units(): """ Return a CircularGaussianPSF with units. """ return (CircularGaussianPSF(flux=1 * u.Jy, x_0=0 * u.arcsec, y_0=0 * u.arcsec, fwhm=1 * u.arcsec, bbox_factor=2), parameters['CircularGaussianPSF']) def circular_gaussian_psf(): """ Return a CircularGaussianPSF without units. """ return (CircularGaussianPSF(flux=2, x_0=1, y_0=1, fwhm=2, bbox_factor=3), parameters['CircularGaussianPSF']) def gaussian_prf_units(): """ Return a GaussianPRF with units. """ return (GaussianPRF(flux=1 * u.Jy, x_0=0 * u.arcsec, y_0=0 * u.arcsec, x_fwhm=1 * u.arcsec, y_fwhm=1 * u.arcsec, theta=0 * u.deg, bbox_factor=2), parameters['GaussianPRF']) def gaussian_prf(): """ Return a GaussianPRF without units. """ return (GaussianPRF(flux=2, x_0=1, y_0=1, x_fwhm=2, y_fwhm=2, theta=0, bbox_factor=3), parameters['GaussianPRF']) def gaussian_psf_units(): """ Return a GaussianPSF with units. """ return (GaussianPSF(flux=1 * u.Jy, x_0=0 * u.arcsec, y_0=0 * u.arcsec, x_fwhm=1 * u.arcsec, y_fwhm=1 * u.arcsec, theta=0 * u.deg, bbox_factor=2), parameters['GaussianPSF']) def gaussian_psf(): """ Return a GaussianPSF without units. """ return (GaussianPSF(flux=2, x_0=1, y_0=1, x_fwhm=2, y_fwhm=2, theta=0, bbox_factor=3), parameters['GaussianPSF']) def airy_disk_prf_units(): """ Return an AiryDiskPRF with units. """ return (AiryDiskPRF(flux=1 * u.Jy, x_0=0 * u.pix, y_0=0 * u.pix, radius=1 * u.pix, bbox_factor=2, n_nodes=5), parameters['AiryDiskPRF']) def airy_disk_prf(): """ Return an AiryDiskPRF without units. """ return (AiryDiskPRF(flux=2, x_0=1, y_0=1, radius=2, bbox_factor=3, n_nodes=7), parameters['AiryDiskPRF']) def moffat_prf_units(): """ Return a MoffatPRF with units. """ return (MoffatPRF(flux=71.4 * u.Jy, x_0=24.3 * u.pix, y_0=25.2 * u.pix, alpha=5.1 * u.pix, beta=3.2, n_nodes=5), parameters['MoffatPRF']) def moffat_prf(): """ Return a MoffatPRF without units. """ return (MoffatPRF(flux=71.4, x_0=24.3, y_0=25.2, alpha=5.1, beta=3.2, n_nodes=7), parameters['MoffatPRF']) def moffat_psf_units(): """ Return a MoffatPSF with units. """ return (MoffatPSF(flux=71.4 * u.Jy, x_0=24.3 * u.pix, y_0=25.2 * u.pix, alpha=5.1 * u.pix, beta=3.2), parameters['MoffatPSF']) def moffat_psf(): """ Return a MoffatPSF without units. """ return (MoffatPSF(flux=71.4, x_0=24.3, y_0=25.2, alpha=5.1, beta=3.2), parameters['MoffatPSF']) def image_psf(): """ Return an ImagePSF without units. """ return (ImagePSF(data=np.arange(36).reshape((6, 6)), flux=6, x_0=0.5, y_0=0.5, oversampling=1, fill_value=0, origin=(0, 0)), parameters['ImagePSF']) def gridded_psf(): """ Return a GriddedPSFModel without units. """ nd_data = NDData(data=np.arange(144).reshape((4, 6, 6)), meta={'oversampling': 1, 'grid_xypos': [(2, 2), (65, 2), (2, 65), (65, 65)], }, ) return (GriddedPSFModel(nddata=nd_data, flux=6, x_0=0.5, y_0=0.5, fill_value=np.nan), parameters['GriddedPSFModel']) def stdpsf_single_detector(): """ Return a STDPSFGrid object read from a FITS STDPSF file. """ psfgrid = STDPSFGrid(PSF_DATA_DIR / 'STDPSF_NRCA1_F150W_mock.fits') return psfgrid, parameters['STDPSFGrid'] def circular_aperture_single_pos(): """ Circular Aperture with a single position. """ return (aperture.CircularAperture(positions=(5, 6), r=7), parameters['CircularAperture']) def circular_aperture_multi_pos(): """ Circular aperture with multiple positions. """ return (aperture.CircularAperture(positions=[(1, 2), (3, 4)], r=5), parameters['CircularAperture']) def circular_annulus_single_pos(): """ Circular annulus aperture with a single position and theta=0. """ return (aperture.CircularAnnulus([10.0, 20.0], 3.0, 5.0), parameters['CircularAnnulus']) def circular_annulus_single_pos_tuple(): """ Circular annulus aperture with a single position as a tuple. """ return (aperture.CircularAnnulus((10.0, 20.0), 3.0, 5.0), parameters['CircularAnnulus']) def circular_annulus_multi_pos(): """ Circular annulus aperture with multiple positions. """ return (aperture.CircularAnnulus([(10, 20), (30, 40)], 3, 5), parameters['CircularAnnulus']) def sky_circular_annulus(): """ Circular annulus aperture on the sky. """ sky = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') return (aperture.SkyCircularAnnulus(sky, 3.0 * u.arcsec, 5.0 * u.arcsec), parameters['CircularAnnulus']) def sky_circular_aperture(): """ Circular aperture on the sky. """ sky = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') return (aperture.SkyCircularAperture(sky, 3.0 * u.arcsec), parameters['CircularAperture']) def elliptical_aperture(theta): """ Elliptical aperture in pix coordinates with several values of theta. Parameters ---------- theta : float, `~astropy.units.Quantity`, `~astropy.coordinates.Angle` Rotation angle. """ return (aperture.EllipticalAperture(positions=(5, 6), a=7, b=3, theta=theta), parameters['EllipticalAperture']) def sky_elliptical_aperture(): """ Elliptical aperture on the sky. """ sky = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') return (aperture.SkyEllipticalAperture(sky, a=3 * u.arcsec, b=5 * u.arcsec, theta=0.5 * u.deg), parameters['EllipticalAperture']) def elliptical_annulus(theta): """ Elliptical annulus aperture in pixel space. Parameters ---------- theta : float, `~astropy.units.Quantity`, `~astropy.coordinates.Angle` Rotation angle. """ return (aperture.EllipticalAnnulus(positions=(5, 6), a_in=3, a_out=5, b_out=5, theta=theta), parameters['EllipticalAnnulus']) def sky_elliptical_annulus(): """ Elliptical annulus aperture on the sky. """ sky = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') return (aperture.SkyEllipticalAnnulus(sky, a_in=3 * u.arcsec, a_out=5 * u.arcsec, b_out=5 * u.arcsec, theta=0.5 * u.deg), parameters['EllipticalAnnulus']) def polygon_aperture(): """ Polygon aperture on pixel space. """ theta = np.linspace(0.0, 2 * np.pi, 6, endpoint=False) offsets = np.column_stack([5.0 * np.cos(theta), 5.0 * np.sin(theta)]) return (aperture.PolygonAperture((10, 20), offsets), parameters['PolygonAperture']) def polygon_aperture_vertices(): """ Polygon aperture in pixel space from vertices. """ verts = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0)] return (aperture.PolygonAperture.from_vertices(verts), parameters['PolygonAperture']) def sky_polygon_aperture(): """ Polygon aperture on the sky. """ sky = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') theta = np.linspace(0, 2 * np.pi, 5, endpoint=False) r = 1.0 offsets = np.column_stack([r * np.cos(theta), r * np.sin(theta)]) * u.arcsec return (aperture.SkyPolygonAperture(sky, offsets), parameters['PolygonAperture']) def rectangular_aperture(theta): """ Rectangular aperture in pixel space. Parameters ---------- theta : float, `~astropy.units.Quantity`, `~astropy.coordinates.Angle` Rotation angle. """ return (aperture.RectangularAperture((10, 20), 5, 3, theta=theta), parameters['RectangularAperture']) def sky_rectangular_aperture(): """ Rectangular aperture on the sky. """ positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') return (aperture.SkyRectangularAperture(positions, 1.0 * u.arcsec, 0.5 * u.arcsec), parameters['RectangularAperture']) def rectangular_annulus(theta): """ Rectangular annulus aperture in pixel space. Parameters ---------- theta : float, `~astropy.units.Quantity`, `~astropy.coordinates.Angle` Rotation angle. """ return (aperture.RectangularAnnulus((10, 20), w_in=3, w_out=5, h_in=2, h_out=4, theta=theta), parameters['RectangularAnnulus']) def sky_rectangular_annulus(): """ Rectangular annulus aperture on the sky. """ positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') theta = Angle(80, 'deg') return (aperture.SkyRectangularAnnulus(positions, w_in=3 * u.arcsec, w_out=5 * u.arcsec, h_in=2 * u.arcsec, h_out=4 * u.arcsec, theta=theta), parameters['RectangularAnnulus']) astropy-photutils-24be529/photutils/converters/tests/test_apertures.py000066400000000000000000000065001526452531300266230ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photutils aperture converters. """ import asdf import pytest from astropy import units as u from astropy.coordinates import Angle from numpy.testing import assert_array_equal from photutils.converters import _ASDF_ASTROPY_INSTALLED from photutils.converters.tests import examples from photutils.extension import PHOTUTILS_APERTURE_CONVERTERS # Examples that take no rotation angle APERTURE_EXAMPLES = [ examples.circular_aperture_single_pos, examples.circular_aperture_multi_pos, examples.circular_annulus_single_pos, examples.circular_annulus_single_pos_tuple, examples.circular_annulus_multi_pos, examples.polygon_aperture, examples.polygon_aperture_vertices, examples.sky_circular_annulus, examples.sky_circular_aperture, examples.sky_elliptical_annulus, examples.sky_elliptical_aperture, examples.sky_polygon_aperture, examples.sky_rectangular_aperture, examples.sky_rectangular_annulus, ] # Examples that take a rotation angle THETA_EXAMPLES = [ examples.elliptical_annulus, examples.elliptical_aperture, examples.rectangular_annulus, examples.rectangular_aperture, ] # Rotation angles as a float, a Quantity, and an Angle THETAS = [0.23, 0.5 * u.deg, Angle(80, 'deg')] def _example_id(example): """ Return the test ID of an example function. """ return example.__name__ def _check_roundtrip(tmp_path, aperture, params): """ Test that an aperture is unchanged by a write and read. """ filename = tmp_path / 'aperture.asdf' with asdf.AsdfFile() as af: af['aper'] = aperture af.write_to(filename) with asdf.open(filename) as af: aperture2 = af['aper'] # The comparisons are made after the file is closed so that they # also check that no lazily-loaded arrays leak into the object for param in params: assert_array_equal(getattr(aperture, param), getattr(aperture2, param)) @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('example', APERTURE_EXAMPLES, ids=_example_id) def test_aperture_converters(tmp_path, example): """ Test that the aperture converters can round-trip an aperture object. """ _check_roundtrip(tmp_path, *example()) @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('theta', THETAS, ids=['float', 'quantity', 'angle']) @pytest.mark.parametrize('example', THETA_EXAMPLES, ids=_example_id) def test_aperture_converters_theta(tmp_path, example, theta): """ Test that the aperture converters can round-trip an aperture rotated at an angle ``theta``. """ _check_roundtrip(tmp_path, *example(theta)) def test_aperture_examples_cover_all_converters(): """ Test that the round-trip tests exercise every type handled by an aperture converter. """ covered = {type(example()[0]).__name__ for example in APERTURE_EXAMPLES} covered |= {type(example(0.0)[0]).__name__ for example in THETA_EXAMPLES} handled = {name.rsplit('.', 1)[-1] for converter in PHOTUTILS_APERTURE_CONVERTERS for name in converter.types} assert covered == handled astropy-photutils-24be529/photutils/converters/tests/test_extension.py000066400000000000000000000117371526452531300266350ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photutils ASDF extension. """ import re from pathlib import Path import pytest import yaml from asdf import get_config from asdf.extension import Converter, ExtensionProxy, ManifestExtension from photutils import converters from photutils.converters import _ASDF_ASTROPY_INSTALLED from photutils.extension import (PHOTUTILS_APERTURE_CONVERTERS, PHOTUTILS_CONVERTERS, PHOTUTILS_MANIFEST_URIS, PHOTUTILS_PSF_CONVERTERS, get_extensions, get_resource_mappings) def _manifest_tags(): """ Return the set of tag URIs defined by the photutils manifests. """ resource_manager = get_config().resource_manager tags = set() for manifest_uri in PHOTUTILS_MANIFEST_URIS: manifest = yaml.safe_load(resource_manager[manifest_uri]) tags.update(tag['tag_uri'] for tag in manifest['tags']) return tags def _converter_tag_prefixes(converters_): """ Return the set of unversioned tag URIs handled by ``converters_``. """ return {tag.removesuffix('-*') for converter in converters_ for tag in converter.tags} def test_converters_implement_asdf_interface(): """ Test that every registered converter implements the asdf Converter interface. asdf rejects an entire extension that contains a converter which does not, so a single invalid converter would disable all photutils ASDF support. """ assert PHOTUTILS_CONVERTERS for converter in PHOTUTILS_CONVERTERS: assert isinstance(converter, Converter) def test_extension_loads(): """ Test that the photutils extension is accepted by asdf. """ extensions = get_extensions() assert len(extensions) == len(PHOTUTILS_MANIFEST_URIS) for extension in extensions: proxy = ExtensionProxy(extension) assert len(proxy.converters) == len(PHOTUTILS_CONVERTERS) def test_aperture_converters_do_not_need_asdf_astropy(): """ Test that the aperture converters alone form a valid extension. The aperture converters need only asdf, so they remain available when the optional asdf-astropy package is not installed. """ assert PHOTUTILS_APERTURE_CONVERTERS extension = ManifestExtension.from_uri( PHOTUTILS_MANIFEST_URIS[0], converters=PHOTUTILS_APERTURE_CONVERTERS) proxy = ExtensionProxy(extension) assert len(proxy.converters) == len(PHOTUTILS_APERTURE_CONVERTERS) def test_psf_converters_require_asdf_astropy(): """ Test that the PSF converters are registered only when asdf-astropy is installed. """ if _ASDF_ASTROPY_INSTALLED: assert PHOTUTILS_PSF_CONVERTERS else: assert PHOTUTILS_PSF_CONVERTERS == [] def test_converter_tags_are_defined_by_the_manifest(): """ Test that every tag handled by a converter is defined by a manifest. """ assert _converter_tag_prefixes(PHOTUTILS_CONVERTERS) manifest_tags = _manifest_tags() for prefix in _converter_tag_prefixes(PHOTUTILS_CONVERTERS): assert any(tag.startswith(prefix) for tag in manifest_tags), prefix def test_manifest_tags_all_have_converters(): """ Test that every tag defined by a manifest has a converter. """ if not _ASDF_ASTROPY_INSTALLED: # The PSF tags are intentionally left without a converter return prefixes = _converter_tag_prefixes(PHOTUTILS_CONVERTERS) for tag in _manifest_tags(): assert any(tag.startswith(prefix) for prefix in prefixes), tag def test_resource_mappings_register_all_files(): """ Test that every schema and manifest file is registered with asdf and resolves to a resource with a matching ``id``. """ resource_manager = get_config().resource_manager uris = set() for mapping in get_resource_mappings(): uris.update(mapping) assert uris for uri in uris: assert uri in resource_manager assert yaml.safe_load(resource_manager[uri])['id'] == uri def test_public_names_are_importable(): """ Test that every name in ``__all__`` is defined. The converters are imported conditionally, so ``__all__`` must be built to match. """ assert converters.__all__ for name in converters.__all__: assert hasattr(converters, name) def test_documented_tags_match_the_manifest(): """ Test that the tags listed in the ASDF documentation match those defined by the manifests. The documentation table is written by hand, so it would otherwise go stale when a tag is added, removed, or renamed. """ docs = (Path(__file__).parents[3] / 'docs' / 'user_guide' / 'asdf.rst') if not docs.is_file(): pytest.skip('the documentation is not part of the installed package') documented = set(re.findall(r'``(photutils/\w+/[\w.-]+)``', docs.read_text())) assert documented == {tag.split(':')[-1] for tag in _manifest_tags()} astropy-photutils-24be529/photutils/converters/tests/test_psf.py000066400000000000000000000216231526452531300254040ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photutils PSF converters. """ import asdf import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.converters import _ASDF_ASTROPY_INSTALLED from photutils.converters.image_models import GriddedPSFModelConverter from photutils.converters.tests import examples from photutils.extension import PHOTUTILS_PSF_CONVERTERS from photutils.psf import STDPSFGrid PSF_EXAMPLES = [ examples.airy_disk_units, examples.airy_disk, examples.circular_gaussian_prf_units, examples.circular_gaussian_prf, examples.circular_gaussian_sigma_prf_units, examples.circular_gaussian_sigma_prf, examples.circular_gaussian_psf_units, examples.circular_gaussian_psf, examples.gaussian_prf_units, examples.gaussian_prf, examples.gaussian_psf_units, examples.gaussian_psf, examples.moffat_psf_units, examples.moffat_psf, examples.airy_disk_prf_units, examples.airy_disk_prf, examples.moffat_prf_units, examples.moffat_prf, examples.image_psf, examples.gridded_psf, examples.stdpsf_single_detector, ] def _example_id(example): """ Return the test ID of an example function. """ return example.__name__ @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('example', PSF_EXAMPLES, ids=_example_id) def test_psf_converters(tmp_path, example): """ Test that the PSF converters can round-trip a PSF object. """ psf, params = example() filename = tmp_path / 'psf.asdf' with asdf.AsdfFile() as af: af['psf'] = psf af.write_to(filename) with asdf.open(filename) as af: psf2 = af['psf'] # The comparisons are made after the file is closed so that they # also check that no lazily-loaded arrays leak into the object for param in params: assert_array_equal(getattr(psf, param), getattr(psf2, param)) # Examples of every PSF model (STDPSFGrid is not a model, so it has # no fitting state) MODEL_EXAMPLES = [ examples.airy_disk, examples.circular_gaussian_prf, examples.circular_gaussian_psf, examples.circular_gaussian_sigma_prf, examples.gaussian_prf, examples.gaussian_psf, examples.moffat_psf, examples.airy_disk_prf, examples.moffat_prf, examples.image_psf, examples.gridded_psf, ] @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('example', MODEL_EXAMPLES, ids=_example_id) def test_psf_converters_preserve_fitting_state(tmp_path, example): """ Test that nondefault fixed, bounds, and name states survive a round-trip. The file stores only the fixed=True entries and the nonempty bounds, so parameters whose class defaults differ from that baseline (e.g., the shape parameters, which default to fixed=True with a lower bound) must be reset when reading. """ psf, _ = example() for name in psf.param_names: psf.fixed[name] = not psf.fixed[name] psf.bounds[name] = (None, None) psf.bounds[psf.param_names[0]] = (0.5, 100.0) psf.name = 'my-psf' filename = tmp_path / 'psf.asdf' with asdf.AsdfFile() as af: af['psf'] = psf af.write_to(filename) with asdf.open(filename) as af: psf2 = af['psf'] assert psf2.name == psf.name assert psf2.fixed == psf.fixed assert psf2.bounds == psf.bounds @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('example', MODEL_EXAMPLES, ids=_example_id) def test_psf_converters_preserve_zero_bounds(tmp_path, example): """ Test that bounds whose limits are all zero or `None` survive a round-trip. The asdf-astropy transform converter does not write these bounds (e.g., a nonnegative flux bound of ``(0, None)``). """ psf, _ = example() psf.bounds[psf.param_names[0]] = (0, None) psf.bounds[psf.param_names[1]] = (None, 0) psf.bounds[psf.param_names[2]] = (0, 0) filename = tmp_path / 'psf.asdf' with asdf.AsdfFile() as af: af['psf'] = psf af.write_to(filename) with asdf.open(filename) as af: psf2 = af['psf'] assert psf2.bounds == psf.bounds @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') def test_psf_examples_cover_all_converters(): """ Test that the round-trip test exercises every type handled by a PSF converter. """ covered = {type(example()[0]).__name__ for example in PSF_EXAMPLES} handled = {name.rsplit('.', 1)[-1] for converter in PHOTUTILS_PSF_CONVERTERS for name in converter.types} assert covered == handled @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') def test_gridded_psf_converter_preserves_modified_oversampling(tmp_path): """ Test that a modified oversampling value survives a round-trip. """ psf, _ = examples.gridded_psf() psf.oversampling = (2, 3) node = GriddedPSFModelConverter().to_yaml_tree_transform(psf, None, None) assert 'oversampling' in node assert 'oversampling' not in node['meta'] filename = tmp_path / 'psf.asdf' with asdf.AsdfFile() as af: af['psf'] = psf af.write_to(filename) with asdf.open(filename) as af: psf2 = af['psf'] assert_array_equal(psf2.oversampling, (2, 3)) assert psf2.meta['oversampling'] == (2, 3) @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize(('example', 'converter_name', 'method'), [ # GriddedPSFModel is an astropy model, so its converter builds the # tree in to_yaml_tree_transform. STDPSFGrid is not a model, so its # converter uses to_yaml_tree. (examples.gridded_psf, 'GriddedPSFModelConverter', 'to_yaml_tree_transform'), (examples.stdpsf_single_detector, 'STDPSFGridConverter', 'to_yaml_tree'), ], ids=['gridded_psf_model', 'stdpsf_grid']) def test_grid_structure_is_stored_outside_meta(example, converter_name, method): """ Test that both grid converters store the grid structure as top-level properties rather than inside ``meta``. GriddedPSFModel keeps the grid structure in its meta attribute and STDPSFGrid keeps it in private attributes, so the converters normalize it to a single layout in the file. """ from photutils.converters import image_models obj, _ = example() converter = getattr(image_models, converter_name)() node = getattr(converter, method)(obj, None, None) for key in ('grid_xypos', 'oversampling'): assert key in node assert key not in node['meta'] assert 'grid_shape' not in node['meta'] @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') def test_stdpsf_grid_converter_preserves_oversampling(tmp_path): """ Test that a nondefault oversampling value survives a round-trip. """ data = np.ones((4, 4, 4)) meta = { 'grid_xypos': np.array([(0, 0), (1, 0), (0, 1), (1, 1)]), 'grid_shape': (2, 2), 'oversampling': 8, 'instrument': 'test-instrument', 'custom': {'value': 42}, } psfgrid = STDPSFGrid._from_asdf(data, meta) filename = tmp_path / 'psf.asdf' with asdf.AsdfFile() as af: af['psf'] = psfgrid af.write_to(filename) with asdf.open(filename) as af: psfgrid2 = af['psf'] assert_array_equal(psfgrid2.oversampling, (8, 8)) assert psfgrid2.meta['instrument'] == 'test-instrument' assert psfgrid2.meta['custom'] == {'value': 42} assert 'grid_xypos' not in psfgrid2.meta assert 'oversampling' not in psfgrid2.meta assert 'grid_shape' not in psfgrid2.meta @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') def test_stdpsf_grid_converter_repeated_grid_coordinate(tmp_path): """ Test a round-trip for an ACS/WFC grid, whose y coordinates are repeated where the two detectors abut. """ filename = examples.PSF_DATA_DIR / 'STDPSF_ACSWFC_F814W_mock.fits' psfgrid = STDPSFGrid(filename) assert psfgrid._grid_shape == (10, 9) asdf_filename = tmp_path / 'psf.asdf' with asdf.AsdfFile() as af: af['psf'] = psfgrid af.write_to(asdf_filename) with asdf.open(asdf_filename) as af: psfgrid2 = af['psf'] assert psfgrid2._grid_shape == psfgrid._grid_shape assert_array_equal(psfgrid2.grid_xypos, psfgrid.grid_xypos) assert_array_equal(psfgrid2._xgrid, psfgrid._xgrid) assert_array_equal(psfgrid2._ygrid, psfgrid._ygrid) assert_array_equal(psfgrid2.data, psfgrid.data) assert repr(psfgrid2) == repr(psfgrid) astropy-photutils-24be529/photutils/converters/tests/test_schemas.py000066400000000000000000000551121526452531300262370ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photutils ASDF manifests and schemas. """ import re import textwrap import asdf import numpy as np import pytest import yaml from asdf import get_config, treeutil from asdf.exceptions import ValidationError from asdf.testing.helpers import yaml_to_asdf from astropy import units as u from astropy.nddata import NDData from numpy.testing import assert_array_equal from photutils.converters import _ASDF_ASTROPY_INSTALLED from photutils.extension import PHOTUTILS_MANIFEST_URIS from photutils.psf import ImagePSF # Matches the top-level YAML tag that opens a schema example. EXAMPLE_TAG_PATTERN = re.compile(r'!<([^>]+)>') PIXEL_POSITIONS = '[10.0, 20.0]' SKY_POSITIONS = """! dec: ! {unit: !unit/unit-1.0.0 deg, value: 20.0} frame: icrs ra: ! {unit: !unit/unit-1.0.0 deg, value: 10.0, wrap_angle: ! {unit: !unit/unit-1.0.0 deg, value: 360.0}} representation_type: spherical""" VERTICES = '[[5.0, 0.0], [0.0, 5.0], [-5.0, 0.0]]' PIXEL_OFFSETS = f'!core/ndarray-1.1.0 {VERTICES}' SKY_OFFSETS = ('!unit/quantity-1.3.0 {unit: !unit/unit-1.0.0 arcsec, ' f'value: !core/ndarray-1.1.0 {VERTICES}}}') # The parameters of each aperture that must agree with ``positions``. # ``theta`` is excluded because the pixel apertures also accept an # angular quantity. SIZE_PARAMS = { 'circular_aperture': ('r',), 'circular_annulus': ('r_in', 'r_out'), 'elliptical_aperture': ('a', 'b'), 'elliptical_annulus': ('a_in', 'a_out', 'b_in', 'b_out'), 'polygon_aperture': ('vertex_offsets',), 'rectangular_aperture': ('w', 'h'), 'rectangular_annulus': ('w_in', 'w_out', 'h_in', 'h_out'), } @pytest.fixture(autouse=True) def _validate_on_read(): """ Validate files against their schemas when they are read. The asdf default will change to skip validation on read, so the tests in this module enable it explicitly. Setting it also avoids the ``AsdfFutureWarning`` that asdf emits when a file fails validation while the option is unset. """ with asdf.config_context() as config: config.validate_on_read = True yield def _load_resource(uri): """ Load a YAML resource registered with ASDF. """ return yaml.safe_load(get_config().resource_manager[uri]) def _iter_examples(schema): """ Yield each entry of every ``examples`` block in a schema. """ for node in treeutil.iter_tree(schema): if isinstance(node, dict) and isinstance(node.get('examples'), list): yield from node['examples'] def _example_text(example): """ Return the example source from a schema ``examples`` entry. Entries are ``[description, text]``, or ``[description, version, text]`` when an ASDF Standard version is pinned. """ return example[2] if len(example) > 2 else example[-1] def _manifest_tags(): """ Map each tag URI defined by the photutils manifests to its schema. """ tags = {} for manifest_uri in PHOTUTILS_MANIFEST_URIS: for tag in _load_resource(manifest_uri)['tags']: tags[tag['tag_uri']] = tag['schema_uri'] return tags def _schema_example_tags(schema): """ Yield the ``(index, tag)`` of every example in a schema. ``tag`` is `None` if the example is not tagged. """ for index, example in enumerate(_iter_examples(schema)): text = _example_text(example).strip() match = EXAMPLE_TAG_PATTERN.match(text) yield index, match[1] if match else None def _example_tags(manifest_tags): """ Collect the top-level tag of every example in every schema. Returns a list of ``(schema_uri, example_index, tag)`` tuples, where ``tag`` is `None` if the example is not tagged. """ examples = [] for schema_uri in dict.fromkeys(manifest_tags.values()): schema = _load_resource(schema_uri) examples.extend((schema_uri, index, tag) for index, tag in _schema_example_tags(schema)) return examples CIRCULAR_APERTURE_URI = ('asdf://astropy.org/photutils/schemas/aperture/' 'circular_aperture-1.0.0') CIRCULAR_ANNULUS_TAG = ('tag:astropy.org:photutils/aperture/' 'circular_annulus-1.0.0') def _circular_aperture_schema(example_tag): """ Build a circular aperture schema whose single example carries ``example_tag``. ``example_tag`` is `None` for an untagged example. """ body = 'positions: [1.0, 2.0]\nr: 5.0\n' if example_tag is not None: body = f'!<{example_tag}>\n' + textwrap.indent(body, ' ') example = textwrap.indent(body, ' ' * 6).rstrip() return f"""%YAML 1.1 --- $schema: "http://stsci.edu/schemas/yaml-schema/draft-01" id: "{CIRCULAR_APERTURE_URI}" title: Circular aperture examples: - - A CircularAperture with a single position and radius of 5 pixels. - | {example} type: object ... """ MANIFEST_TAGS = _manifest_tags() EXAMPLE_TAGS = _example_tags(MANIFEST_TAGS) EXAMPLE_IDS = [f'{uri.rsplit("/", 1)[-1]}-example{index}' for uri, index, _ in EXAMPLE_TAGS] def test_manifest_schemas_are_registered(): """ Test that every schema referenced by the manifests is registered. """ assert MANIFEST_TAGS for tag_uri, schema_uri in MANIFEST_TAGS.items(): schema = _load_resource(schema_uri) assert schema['id'] == schema_uri, tag_uri def _check_example_tag(schema_uri, index, tag): """ Check that a schema example carries the manifest tag of the schema that contains it. """ assert tag is not None, f'{schema_uri} example {index} is untagged' assert tag in MANIFEST_TAGS, f'{tag} is not defined in the manifest' assert MANIFEST_TAGS[tag] == schema_uri, ( f'{tag} is defined by a different schema') @pytest.mark.parametrize(('schema_uri', 'index', 'tag'), EXAMPLE_TAGS, ids=EXAMPLE_IDS) def test_example_tag_in_manifest(schema_uri, index, tag): """ Test that each schema example is tagged with the manifest tag of the schema that contains it. The ASDF schema example self-tests validate a tagged tree, so the schema applied to an example is selected by its tag. An unrecognized tag selects no schema at all, which makes the example self-test pass without validating anything. """ _check_example_tag(schema_uri, index, tag) @pytest.mark.parametrize(('example_tag', 'match'), [ (None, 'example 0 is untagged'), ('tag:astropy.org:photutils/aperture/circular_apperture-1.0.0', 'is not defined in the manifest'), (CIRCULAR_ANNULUS_TAG, 'is defined by a different schema')], ids=['untagged', 'misspelled', 'other_schema']) def test_invalid_example_tag_is_detected(example_tag, match): """ Test that a schema example carrying an invalid tag is rejected. The tag is extracted from the schema source, so this exercises the same path as the schemas registered by the manifests. """ schema = yaml.safe_load(_circular_aperture_schema(example_tag)) [(index, tag)] = _schema_example_tags(schema) assert tag == example_tag with pytest.raises(AssertionError, match=match): _check_example_tag(schema['id'], index, tag) def _quantity(value): """ Build the YAML for a scalar angular quantity. """ return ('!unit/quantity-1.3.0 ' f'{{unit: !unit/unit-1.0.0 arcsec, value: {value}}}') def _sizes(names, *, sky=False): """ Build the size parameters of an aperture in pixel or sky form. """ values = {} for index, name in enumerate(names): if name == 'vertex_offsets': values[name] = SKY_OFFSETS if sky else PIXEL_OFFSETS elif sky: values[name] = _quantity(index + 3.0) else: values[name] = f'{index + 3.0}' return values def _aperture_yaml(stem, positions, sizes): """ Build the tagged YAML for a photutils aperture. """ params = {'positions': positions, **sizes} body = '\n'.join(f' {key}: {value}' for key, value in params.items()) return f'!\n{body}' def _mixed_apertures(): """ Build apertures that mix pixel values with angular quantities. """ mixed = [] for stem, names in SIZE_PARAMS.items(): mixed.append((f'{stem}-sky-positions', _aperture_yaml(stem, SKY_POSITIONS, _sizes(names, sky=False)))) mixed.append((f'{stem}-sky-sizes', _aperture_yaml(stem, PIXEL_POSITIONS, _sizes(names, sky=True)))) return mixed MIXED_APERTURES = _mixed_apertures() @pytest.mark.parametrize('example', [case[1] for case in MIXED_APERTURES], ids=[case[0] for case in MIXED_APERTURES]) def test_mixed_pixel_and_sky_rejected(example): """ Test that an aperture mixing pixel values with angular quantities fails schema validation when read. The pixel and sky variants of each aperture share a tag, so this combination is excluded only by the ``oneOf`` in the schema. Valid pixel and sky files are covered by the round-trip tests in ``test_apertures.py``. """ with pytest.raises(ValidationError), \ asdf.open(yaml_to_asdf(f'aper: {example}')): pass # The size parameters that each aperture schema requires. The remaining # parameters of these apertures (``theta`` for all of them, plus the # inner size of the annuli) are optional. REQUIRED_SIZE_PARAMS = { 'elliptical_aperture': ('a', 'b'), 'elliptical_annulus': ('a_in', 'a_out', 'b_out'), 'rectangular_aperture': ('w', 'h'), 'rectangular_annulus': ('w_in', 'w_out', 'h_out'), } def _read_minimal_aperture(stem, *, sky): """ Read an aperture from a file containing only its required parameters. """ example = _aperture_yaml(stem, SKY_POSITIONS if sky else PIXEL_POSITIONS, _sizes(REQUIRED_SIZE_PARAMS[stem], sky=sky)) with asdf.open(yaml_to_asdf(f'aper: {example}')) as af: return af['aper'] @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('sky', [False, True], ids=['pixel', 'sky']) @pytest.mark.parametrize('stem', list(REQUIRED_SIZE_PARAMS)) def test_optional_theta_uses_default(stem, sky): """ Test that an aperture file that omits ``theta`` is read using the default rotation angle of its aperture class. ``theta`` is optional in both the aperture classes and the schemas, and its default differs between the pixel and sky apertures. """ aperture = _read_minimal_aperture(stem, sky=sky) assert aperture.theta == (0.0 * u.deg if sky else 0.0) @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('sky', [False, True], ids=['pixel', 'sky']) def test_optional_inner_size_uses_default(sky): """ Test that an annulus file that omits its inner size is read using the inner size derived by its aperture class. ``b_in`` and ``h_in`` are optional in both the aperture classes and the schemas. """ ellipse = _read_minimal_aperture('elliptical_annulus', sky=sky) assert ellipse.b_in == ellipse.b_out * ellipse.a_in / ellipse.a_out rectangle = _read_minimal_aperture('rectangular_annulus', sky=sky) assert (rectangle.h_in == rectangle.w_in * rectangle.h_out / rectangle.w_out) @pytest.mark.parametrize('stem', list(REQUIRED_SIZE_PARAMS)) def test_sky_numeric_theta_rejected(stem): """ Test that a sky aperture with a numeric ``theta`` fails schema validation when read. The sky apertures require an angular ``theta``, so a number is excluded by the sky branch of the ``oneOf`` in the schema. """ sizes = _sizes(REQUIRED_SIZE_PARAMS[stem], sky=True) example = _aperture_yaml(stem, SKY_POSITIONS, {**sizes, 'theta': '0.5'}) with pytest.raises(ValidationError), \ asdf.open(yaml_to_asdf(f'aper: {example}')): pass @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('stem', list(REQUIRED_SIZE_PARAMS)) def test_pixel_angular_theta_accepted(stem): """ Test that a pixel aperture with an angular ``theta`` is read without error. The pixel apertures accept either a number or an angular quantity, so ``theta`` is constrained only in the sky branch of the ``oneOf`` in the schema. """ sizes = _sizes(REQUIRED_SIZE_PARAMS[stem], sky=False) example = _aperture_yaml(stem, PIXEL_POSITIONS, {**sizes, 'theta': _quantity(0.5)}) with asdf.open(yaml_to_asdf(f'aper: {example}')) as af: assert af['aper'].theta == 0.5 * u.arcsec @pytest.mark.parametrize('stem', list(SIZE_PARAMS)) def test_unknown_property_rejected(stem): """ Test that an aperture with an unknown property fails schema validation when read. Without ``additionalProperties: false`` an unknown key is accepted and then silently dropped, so a misspelled parameter would read back as an aperture with default values. """ params = _sizes(SIZE_PARAMS[stem], sky=False) params['not_a_parameter'] = '999.0' example = _aperture_yaml(stem, PIXEL_POSITIONS, params) with pytest.raises(ValidationError), \ asdf.open(yaml_to_asdf(f'aper: {example}')): pass def _ndarray(values): """ Build the inline YAML for an ndarray. """ return f'!core/ndarray-1.1.0 {np.asarray(values).tolist()}' # A 4x4 image, the smallest accepted by the image-based PSF models, # four copies of it (one per grid position), and the grid positions. PSF_IMAGE_VALUES = np.arange(16.0).reshape((4, 4)) PSF_GRID_VALUES = np.repeat(PSF_IMAGE_VALUES[np.newaxis], 4, axis=0) GRID_XYPOS_VALUES = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] PSF_IMAGE = _ndarray(PSF_IMAGE_VALUES) PSF_GRID = _ndarray(PSF_GRID_VALUES) GRID_XYPOS = _ndarray(GRID_XYPOS_VALUES) # The parameters that each PSF schema requires. The remaining # parameters are optional. ``bbox_factor`` is optional in every # functional-model schema, as is ``theta`` for the elliptical Gaussians # and ``oversampling`` for the STDPSF grid. REQUIRED_PSF_PARAMS = { 'airy_disk_prf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'radius': 4.0}, 'airy_disk_psf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'radius': 4.0}, 'circular_gaussian_prf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'fwhm': 4.0}, 'circular_gaussian_psf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'fwhm': 4.0}, 'circular_gaussian_sigma_prf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'sigma': 4.0}, 'gaussian_prf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'x_fwhm': 4.0, 'y_fwhm': 5.0}, 'gaussian_psf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'x_fwhm': 4.0, 'y_fwhm': 5.0}, 'moffat_prf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'alpha': 4.0, 'beta': 5.0}, 'moffat_psf': {'flux': 1.0, 'x_0': 2.0, 'y_0': 3.0, 'alpha': 4.0, 'beta': 5.0}, 'image_psf': {'data': PSF_IMAGE}, 'gridded_psf_model': {'data': PSF_GRID, 'oversampling': '!core/ndarray-1.1.0 [1, 1]', 'grid_xypos': GRID_XYPOS, 'meta': '{}'}, 'stdpsf_grid': {'data': PSF_GRID, 'grid_xypos': GRID_XYPOS, 'grid_shape': '[2, 2]', 'meta': '{}'}, } # The models whose parameters are all plain numbers, so that a reference # model can be built from the same required parameters. FUNCTIONAL_PSF_STEMS = [ 'airy_disk_prf', 'airy_disk_psf', 'circular_gaussian_prf', 'circular_gaussian_psf', 'circular_gaussian_sigma_prf', 'gaussian_prf', 'gaussian_psf', 'moffat_prf', 'moffat_psf', ] def _psf_yaml(stem, params): """ Build the tagged YAML for a photutils PSF model. """ body = '\n'.join(f' {key}: {value}' for key, value in params.items()) return f'!\n{body}' def _read_psf(stem, params): """ Read a PSF model from a file containing only ``params``. """ with asdf.open(yaml_to_asdf(f'psf: {_psf_yaml(stem, params)}')) as af: return af['psf'] @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('stem', FUNCTIONAL_PSF_STEMS) def test_optional_psf_params_use_defaults(stem): """ Test that a PSF file containing only the parameters required by its schema is read using the defaults of its model class. ``bbox_factor`` is optional in every functional-model schema, as is ``theta`` for the elliptical Gaussian models, so a schema-valid file may omit them. """ params = REQUIRED_PSF_PARAMS[stem] model = _read_psf(stem, params) reference = type(model)(**params) for name, value in params.items(): assert getattr(model, name) == value assert model.bbox_factor == reference.bbox_factor if hasattr(model, 'n_nodes'): assert model.n_nodes == reference.n_nodes if hasattr(model, 'theta'): assert model.theta == reference.theta @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') def test_optional_image_psf_params_use_defaults(): """ Test that an ImagePSF file containing only ``data`` is read using the defaults of the model class. """ model = _read_psf('image_psf', REQUIRED_PSF_PARAMS['image_psf']) reference = ImagePSF(data=PSF_IMAGE_VALUES) assert_array_equal(model.data, PSF_IMAGE_VALUES) for name in ('flux', 'x_0', 'y_0', 'fill_value'): assert getattr(model, name) == getattr(reference, name) assert_array_equal(model.oversampling, reference.oversampling) assert_array_equal(model.origin, reference.origin) @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') def test_optional_gridded_psf_model_params_use_defaults(): """ Test that a GriddedPSFModel file containing only the parameters required by its schema is read using the defaults of the model class. """ from photutils.psf import GriddedPSFModel model = _read_psf('gridded_psf_model', REQUIRED_PSF_PARAMS['gridded_psf_model']) nddata = NDData(data=PSF_GRID_VALUES, meta={'oversampling': 1, 'grid_xypos': GRID_XYPOS_VALUES}) reference = GriddedPSFModel(nddata=nddata) assert_array_equal(model.data, PSF_GRID_VALUES) for name in ('flux', 'x_0', 'y_0', 'fill_value'): assert getattr(model, name) == getattr(reference, name) @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') def test_optional_stdpsf_grid_params_use_defaults(): """ Test that a STDPSFGrid file containing only the parameters required by its schema is read using the default oversampling. ``oversampling`` is optional in the schema, and STDPSFGrid assumes a factor of 4 along both axes when it is absent. """ grid = _read_psf('stdpsf_grid', REQUIRED_PSF_PARAMS['stdpsf_grid']) assert_array_equal(grid.data, PSF_GRID_VALUES) assert_array_equal(grid.grid_xypos, GRID_XYPOS_VALUES) assert_array_equal(grid.oversampling, (4, 4)) # The oversampling form not otherwise exercised by each model's # round-trip tests. The converters write an ndarray for ImagePSF and # GriddedPSFModel and a plain integer array for STDPSFGrid. OTHER_OVERSAMPLING_FORMS = { 'image_psf': '[2, 2]', 'gridded_psf_model': '[1, 1]', 'stdpsf_grid': '!core/ndarray-1.1.0 [4, 4]', } @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('stem', list(OTHER_OVERSAMPLING_FORMS)) def test_oversampling_forms_accepted(stem): """ Test that ``oversampling`` may be a plain integer array or an ndarray in every image-based PSF schema. """ params = {**REQUIRED_PSF_PARAMS[stem], 'oversampling': OTHER_OVERSAMPLING_FORMS[stem]} model = _read_psf(stem, params) expected = yaml.safe_load( OTHER_OVERSAMPLING_FORMS[stem].removeprefix('!core/ndarray-1.1.0')) assert_array_equal(model.oversampling, expected) @pytest.mark.parametrize('stem', list(OTHER_OVERSAMPLING_FORMS)) def test_float_oversampling_rejected(stem): """ Test that a noninteger ``oversampling`` array fails schema validation when read. The oversampling factors must be integers, so rejecting floats in the schema reports the problem as a validation error instead of a model initialization error. """ params = {**REQUIRED_PSF_PARAMS[stem], 'oversampling': '[2.5, 2.5]'} with pytest.raises(ValidationError), \ asdf.open(yaml_to_asdf(f'psf: {_psf_yaml(stem, params)}')): pass @pytest.mark.parametrize('stem', list(REQUIRED_PSF_PARAMS)) def test_unknown_psf_property_rejected(stem): """ Test that a PSF model with an unknown property fails schema validation when read. Without ``additionalProperties: false`` an unknown key is accepted and then silently dropped, so a misspelled parameter would read back as a model with default values. The properties of the transform schema referenced by the PSF models are repeated in each schema so that they remain allowed. """ params = {**REQUIRED_PSF_PARAMS[stem], 'not_a_parameter': '999.0'} with pytest.raises(ValidationError), \ asdf.open(yaml_to_asdf(f'psf: {_psf_yaml(stem, params)}')): pass @pytest.mark.skipif(not _ASDF_ASTROPY_INSTALLED, reason='asdf-astropy is not installed') @pytest.mark.parametrize('stem', FUNCTIONAL_PSF_STEMS) def test_transform_properties_accepted(stem): """ Test that the properties of the referenced transform schema are accepted by the PSF schemas. They are repeated in each schema so that ``additionalProperties`` rejects only unknown keys. """ params = {**REQUIRED_PSF_PARAMS[stem], 'name': 'psf', 'inputs': '[x, y]', 'outputs': '[z]'} model = _read_psf(stem, params) assert model.name == 'psf' assert model.inputs == ('x', 'y') assert model.outputs == ('z',) astropy-photutils-24be529/photutils/datasets/000077500000000000000000000000001526452531300214535ustar00rootroot00000000000000astropy-photutils-24be529/photutils/datasets/__init__.py000066400000000000000000000010751526452531300235670ustar00rootroot00000000000000# 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-24be529/photutils/datasets/data/000077500000000000000000000000001526452531300223645ustar00rootroot00000000000000astropy-photutils-24be529/photutils/datasets/data/100gaussians_params.ecsv000066400000000000000000000317021526452531300270320ustar00rootroot00000000000000# %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-24be529/photutils/datasets/data/4gaussians_params.ecsv000066400000000000000000000007151526452531300266750ustar00rootroot00000000000000# %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-24be529/photutils/datasets/examples.py000066400000000000000000000106321526452531300236450ustar00rootroot00000000000000# 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 constant background has a value 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. The noise is generated with a fixed random seed, so the output image is the same for every call. Parameters ---------- noise : bool, optional Whether to include noise in the output image (default is `True`). Returns ------- image : 2D `~numpy.ndarray` The image, with shape (100, 200), 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 constant background has a value 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. The noise is generated with a fixed random seed, so the output image is the same for every call. Parameters ---------- noise : bool, optional Whether to include noise in the output image (default is `True`). Returns ------- image : 2D `~numpy.ndarray` The image, with shape (300, 500), 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-24be529/photutils/datasets/images.py000066400000000000000000000447471526452531300233120ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for making simulated images from astropy models. """ 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 from astropy.table import Table from photutils.utils._overlap import overlap_slices 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 or int The shape of the output image. If a single integer is provided, it will be used for both dimensions. model : 2D `astropy.modeling.Model` The 2D model to be used to render the sources. The model must be two-dimensional, meaning that 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`` keep the values of the input ``model``. To attach units to model parameters, ``params_table`` must be input as a `~astropy.table.QTable`. Rows that contain any nonfinite model parameters or a nonfinite ``'local_bkg'`` value 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 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 the ``'model_shape'`` and ``'local_bkg'`` columns, columns whose names do not match a model parameter name are ignored unless they are mapped to a model parameter by ``params_map``. model_shape : 2-tuple of int, int, or `None`, optional The shape around the (x, y) center of each source that will be 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). If the model does not have a ``bounding_box`` attribute, then either this keyword or a ``'model_shape'`` column in ``params_table`` must be specified. 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. This value must also be a column name in ``params_table`` unless ``params_map`` maps this parameter to a different column. y_name : str, optional The name of the ``model`` parameter that corresponds to the y position of the sources. This value must also be a column name in ``params_table`` unless ``params_map`` maps this parameter to a different column. 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 dictionary may also be used to map the model x and y parameters to columns with names other than ``x_name`` and ``y_name``. In that case, the dictionary keys for the positions must be the ``x_name`` and ``y_name`` parameter names. If those keys are not in the dictionary, the ``x_name`` and ``y_name`` columns are used. 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, which are already integrated over the pixels. * ``'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.dblquad`. This mode conserves the model integral on a subpixel scale, but it is *extremely* slow. The ``'oversample'`` or ``'integrate'`` method should be used for a model that is not integrated over the pixels, such as the `photutils.psf` analytic models whose names end in ``PSF``. Neither should be used for an ePSF/PRF model, which would then be integrated over the pixels twice. discretize_oversample : int, optional The integer oversampling factor used when ``discretize_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` or `~astropy.units.Quantity` The rendered image containing the model sources. The image is a `~astropy.units.Quantity` if the model output has units (e.g., if ``params_table`` is a `~astropy.table.QTable` with a flux column that has units). Raises ------ TypeError If ``model`` is not an `astropy.modeling.Model` instance or if ``params_table`` is not an `~astropy.table.Table`. ValueError If ``model`` does not have 2 inputs and 1 output, if ``discretize_method`` is invalid, if a ``params_map`` key is not a model parameter name, if a required column is not in ``params_table``, if the model has no ``bounding_box`` attribute and no model shape is specified, if the ``'local_bkg'`` column does not have the same flux units as the output image, or if a ``params_table`` column has units and ``params_table`` is not a `~astropy.table.QTable`. 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() """ shape = as_pair('shape', shape, lower_bound=(0, 0)) 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) valid_methods = ('center', 'interp', 'oversample', 'integrate') if discretize_method not in valid_methods: msg = (f'Invalid discretize_method: {discretize_method!r}. ' f'discretize_method must be one of {valid_methods}') raise ValueError(msg) if discretize_method == 'interp': discretize_method = 'linear_interp' 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([as_pair('model_shape', mshape, lower_bound=(0, 0)) for mshape in params_table['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() image = np.zeros(shape, dtype=float) # Determine the output image units, if any, by evaluating the model # with unit scalars derived from the mapped table columns. Unmapped # model parameters keep their default values (and units). This is # done up front so that the output units do not depend on whether # any sources are actually rendered (e.g., a zero-row table or # sources that do not overlap the image). for key, param in params_map.items(): unit = getattr(params_table[param], 'unit', None) setattr(model, key, 1 if unit is None else 1 * unit) result = model(0, 0) if isinstance(result, u.Quantity): image <<= result.unit if progress_bar: desc = 'Add model sources' params_table = add_progress_bar(params_table, desc=desc) for i, source in enumerate(params_table): params = {key: source[param] for key, param in params_map.items()} # Skip sources that have any nonfinite parameter or local # background value if (not all(np.isfinite(value) for value in params.values()) or not np.isfinite(local_bkg[i])): continue try: for key, value in params.items(): setattr(model, key, value) except u.UnitsError as exc: msg = ('params_table must be a QTable when its columns have ' 'units') raise ValueError(msg) from exc # 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') except NoOverlapError: continue if discretize_method == 'center': yy, xx = np.mgrid[slc_lg] subimg = model(xx, yy) else: 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) 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 return image 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 be 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-24be529/photutils/datasets/load.py000066400000000000000000000205611526452531300227500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for loading example datasets, from both within photutils and remote servers. """ from astropy.io import fits from astropy.table import Table from astropy.utils.data import download_file, get_pkg_data_filename from photutils.utils._deprecation import deprecated __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'}, optional File location. ``'local'`` means bundled with ``photutils``. If the file is not bundled, the astropy data server is tried. ``'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. The default is ``'local'``. 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}' # Use download_file's ``sources`` argument to try the primary # astropy data URL first and fall back to the photutils-datasets # repo. The result is cached under the primary URL regardless # of which source was used. If the file is already in the # cache, download_file returns the local path without any # network access. path = download_file(url, cache=cache, sources=[url, datasets_url], 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', until='4.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'}, optional File location. ``'local'`` means bundled with ``photutils``. If the file is not bundled, the astropy data server is tried. ``'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. The default is ``'local'``. 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', until='4.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', until='4.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, with Galactic coordinates, 4.5 micron fluxes, and magnitudes in the four IRAC channels. """ path = _get_path('spitzer_example_catalog.xml', location='remote', show_progress=show_progress) return Table.read(path) @deprecated(since='3.1', until='4.0') def load_irac_psf(channel, show_progress=False): """ Load a Spitzer IRAC PSF image. Parameters ---------- channel : {1, 2, 3, 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. The PSF is oversampled by a factor of 4 relative to the detector pixels and is normalized to a sum of 1. Raises ------ ValueError If ``channel`` is not 1, 2, 3, or 4. """ 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', until='4.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(since='3.1', 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. """ path = _get_path('hst_wfc3ir_f160w_simulated_starfield.fits', location='photutils-datasets', show_progress=show_progress) return _load_fits_as_imagehdu(path) astropy-photutils-24be529/photutils/datasets/model_params.py000066400000000000000000000341121526452531300244710ustar00rootroot00000000000000# 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.modeling import Model 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, the output table contains only the source ``id`` and the ``x_0`` and ``y_0`` positions. Additional parameters can be specified as keyword arguments with their lower and upper bounds as 2-tuples. They are added as columns in the order given. The parameter values will be uniformly sampled from the half-open interval ``[lower, upper)``. Parameters ---------- shape : 2-tuple of int or int The (ny, nx) shape of the image in which the sources will be placed. If a single integer is provided, it is used for both dimensions. n_sources : int The number of sources to generate. If ``min_separation`` is too large, the requested number of sources may not fit within the given ``shape``. In that case, fewer than ``n_sources`` sources are generated and an `~astropy.utils.exceptions.AstropyUserWarning` is issued. 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 in pixels between the centers of two sources. If the minimum separation is too large, fewer than ``n_sources`` sources are generated and a warning is issued. 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. The x positions are uniformly sampled from the half-open interval ``[nx_border, shape[1] - nx_border)`` and the y positions from ``[ny_border, shape[0] - ny_border)``. 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 bounds must be numbers without units. The parameter values will be uniformly sampled from the half-open interval ``[lower, upper)``. The names ``id``, ``x_name``, and ``y_name`` cannot be used because they are the names of the ID and position columns. 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. Raises ------ ValueError If ``n_sources`` is negative, if ``border_size`` leaves no room for sources within the given ``shape``, if a keyword argument value is not a 2-tuple, or if a keyword argument is named ``id`` or has the same name as ``x_name`` or ``y_name``. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If fewer than ``n_sources`` sources could be generated with the given ``shape`` and ``min_separation``. 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)) if n_sources < 0: msg = 'n_sources must be >= 0' raise ValueError(msg) 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.meta.update(_get_meta()) # keep model_params.meta type 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 param in ('id', x_name, y_name): msg = (f'{param} cannot be used as a keyword argument because ' 'it is the name of the ID or a position column') raise ValueError(msg) if np.shape(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 uniformly sampled from the half-open intervals ``[lower, upper)`` given by the specified input ranges. 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 bounds must be numbers without units. The parameter values will be uniformly sampled from the half-open interval ``[lower, upper)``. The key ``'id'`` cannot be used because it is the name of the source ID column. 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. Raises ------ ValueError If ``n_sources`` is negative, if a ``param_ranges`` value is not a 2-tuple, or if ``param_ranges`` contains an ``'id'`` key. Notes ----- To generate identical parameter values from separate function calls, ``n_sources``, the ``seed`` (which must not be `None`), and the parameter ranges in ``param_ranges``, including the order of its keys, must all be the same. Examples -------- >>> import numpy as np >>> 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 """ if n_sources < 0: msg = 'n_sources must be >= 0' raise ValueError(msg) if 'id' in param_ranges: msg = ("param_ranges cannot contain an 'id' key because it is the " 'name of the source ID column') raise ValueError(msg) 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, prange in param_ranges.items(): if np.shape(prange) != (2,): msg = f'{param_name} must be a 2-tuple' raise ValueError(msg) # 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(prange[0], prange[1], 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 keep the values of the input ``model``. Columns whose names do not match a model parameter name are ignored. At least one column name must match a parameter name of ``model``. 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` instance The model to copy for each row of ``params_table``. The parameters of each copy are set from the table. The input ``model`` is not modified. Returns ------- models : list of `astropy.modeling.Model` A list of models created from the input table of model parameters. Raises ------ TypeError If ``model`` is not an `astropy.modeling.Model` instance. ValueError If no column name in ``params_table`` matches a parameter name of ``model``. 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 [, , ] """ if not isinstance(model, Model): msg = 'model must be a Model instance' raise TypeError(msg) 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 = [*sorted(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-24be529/photutils/datasets/noise.py000066400000000000000000000124201526452531300231410ustar00rootroot00000000000000# 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 finite, nonnegative 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``. Raises ------ ValueError If ``data`` contains any nonfinite or negative values. 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(nrows=2, 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 not np.all(np.isfinite(data)): msg = 'data must not contain any nonfinite values' raise ValueError(msg) 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'}, optional 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. stddev : float, optional The standard deviation of the Gaussian distribution. 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. The dtype is float for Gaussian noise and int for Poisson noise. Raises ------ ValueError If ``mean`` is `None`, if ``stddev`` is `None` for Gaussian noise, or if ``distribution`` is invalid. 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(ncols=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-24be529/photutils/datasets/tests/000077500000000000000000000000001526452531300226155ustar00rootroot00000000000000astropy-photutils-24be529/photutils/datasets/tests/__init__.py000066400000000000000000000000001526452531300247140ustar00rootroot00000000000000astropy-photutils-24be529/photutils/datasets/tests/test_examples.py000066400000000000000000000023731526452531300260510ustar00rootroot00000000000000# 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-24be529/photutils/datasets/tests/test_images.py000066400000000000000000000364021526452531300255000ustar00rootroot00000000000000# 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, Table 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 # Test scalar shape del params['model_shape'] del params['local_bkg'] image = make_model_image(300, model, params, model_shape=model_shape) assert image.shape == (300, 300) def test_make_model_image_variable_shape_pairs(): """ Test a model_shape column containing (ny, nx) pairs. """ 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] params['model_shape'] = np.array([[9, 7], [7, 9], [11, 11]]) model = Moffat2D(amplitude=1) shape = (300, 500) image = make_model_image(shape, model, params) assert image.shape == shape assert image.sum() > 1 def test_make_model_image_variable_shape_invalid(): """ Test that invalid values in a model_shape column raise an error. """ params = QTable() params['x_0'] = [50, 70] params['y_0'] = [50, 50] params['gamma'] = [1.7, 2.32] params['alpha'] = [2.9, 5.7] model = Moffat2D(amplitude=1) shape = (300, 500) match = 'model_shape must be > 0' for model_shapes in ([0, 11], [-5, 11]): params['model_shape'] = model_shapes with pytest.raises(ValueError, match=match): make_model_image(shape, model, params) match = 'model_shape must be a finite value' params['model_shape'] = [np.nan, 11.0] with pytest.raises(ValueError, match=match): make_model_image(shape, model, params) def test_make_model_image_nonfinite_local_bkg(): """ Test that sources with a nonfinite local_bkg value are skipped. """ params = QTable() params['x_0'] = [50.0, 70.0, 90.0] params['y_0'] = [50.0, 50.0, 50.0] params['gamma'] = [1.7, 2.32, 5.8] params['alpha'] = [2.9, 5.7, 4.6] model = Moffat2D(amplitude=1) shape = (100, 150) model_shape = (11, 11) params['local_bkg'] = [0.0, 0.0, 0.0] image0 = make_model_image(shape, model, params, model_shape=model_shape) params['local_bkg'] = [np.nan, 0.0, np.inf] image = make_model_image(shape, model, params, model_shape=model_shape) assert not np.any(np.isnan(image)) assert image[50, 50] == 0 # first source skipped assert image[50, 90] == 0 # third source skipped assert_allclose(image[50, 70], image0[50, 70]) 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_units_no_sources_rendered(): """ Test that the output units do not depend on whether any sources are actually rendered. """ unit = u.Jy model = CircularGaussianSigmaPRF(sigma=1.5) shape = (10, 12) # Zero-row table params = QTable() params['x_0'] = np.array([], dtype=float) params['y_0'] = np.array([], dtype=float) params['flux'] = np.array([], dtype=float) * unit image = make_model_image(shape, model, params, model_shape=(5, 5)) assert isinstance(image, u.Quantity) assert image.unit == unit assert np.all(image.value == 0) # All rows nonfinite params = QTable() params['x_0'] = [np.nan] params['y_0'] = [5.0] params['flux'] = [1.0] * unit image = make_model_image(shape, model, params, model_shape=(5, 5)) assert isinstance(image, u.Quantity) assert image.unit == unit assert np.all(image.value == 0) # Zero-row table without units params = QTable() params['x_0'] = np.array([], dtype=float) params['y_0'] = np.array([], dtype=float) image = make_model_image(shape, model, params, model_shape=(5, 5)) assert not isinstance(image, u.Quantity) assert np.all(image == 0) 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 @pytest.mark.parametrize('per_source', [False, True]) def test_make_model_image_edge_no_overlap(per_source): """ Test that a source whose model box ends exactly at the lower edge of the image is skipped. Regression test for the array-valued ``model_shape`` being compared to a tuple in ``overlap_slices``. """ params = QTable() params['x_0'] = [50.0, -16.0, 50.0] params['y_0'] = [50.0, 50.0, -16.0] params['flux'] = [100.0, 100.0, 100.0] model = CircularGaussianPSF(fwhm=2.0) shape = (100, 100) model_shape = (31, 31) ref_image = make_model_image(shape, model, params[:1], model_shape=model_shape) if per_source: params['model_shape'] = [31, 31, 31] image = make_model_image(shape, model, params) else: image = make_model_image(shape, model, params, model_shape=model_shape) assert_allclose(image, ref_image) def test_make_model_image_inputs(): """ Test that the appropriate exceptions are raised for invalid inputs. """ match = 'shape must have 1 or 2 elements' with pytest.raises(ValueError, match=match): make_model_image((100, 100, 3), Moffat2D(), QTable()) match = 'shape must be > 0' with pytest.raises(ValueError, match=match): make_model_image((-100, 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) match = 'Invalid discretize_method' with pytest.raises(ValueError, match=match): make_model_image(shape, model, params, model_shape=(11, 11), discretize_method='invalid') def test_make_model_image_table_units(): """ Test that a column with units in a plain Table raises a clear error. """ params = Table() params['x_0'] = [10.0] params['y_0'] = [10.0] params['amplitude'] = [100.0] * u.Jy match = 'params_table must be a QTable' with pytest.raises(ValueError, match=match): make_model_image((21, 21), Moffat2D(), params, model_shape=(11, 11)) image = make_model_image((21, 21), Moffat2D(), QTable(params), model_shape=(11, 11)) assert image.unit == u.Jy 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) 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 nonfinite 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-24be529/photutils/datasets/tests/test_load.py000066400000000000000000000363511526452531300251550ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the load module. """ from unittest.mock import patch import numpy as np import pytest from astropy.io import fits from astropy.table import Table from photutils.datasets import load from photutils.datasets.load import _get_path, _load_fits_as_imagehdu from photutils.utils.exceptions import PhotutilsDeprecationWarning 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 remote location 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 TestGetPathRemote: """ Tests for the behavior of _get_path with location='remote'. These tests verify that ``download_file`` is called with both the primary astropy data URL and the photutils-datasets fallback URL passed as ``sources``, so that astropy handles the cache lookup and fallback behavior automatically. """ def test_calls_download_file_with_sources(self, url_paths): """ Test that _get_path calls ``download_file`` with the primary URL as the cache key and both URLs in ``sources``. """ with patch('photutils.datasets.load.download_file') as mock_dl: 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, sources=[url_paths['primary_url'], url_paths['datasets_url']], show_progress=False, ) def test_passes_cache_and_show_progress(self, url_paths): """ Test that the ``cache`` and ``show_progress`` arguments are forwarded to ``download_file``. """ with patch('photutils.datasets.load.download_file') as mock_dl: mock_dl.return_value = '/downloaded/path/test_file.fits' result = _get_path(url_paths['filename'], location='remote', cache=False, show_progress=True) assert result == '/downloaded/path/test_file.fits' mock_dl.assert_called_once_with( url_paths['primary_url'], cache=False, sources=[url_paths['primary_url'], url_paths['datasets_url']], show_progress=True, ) 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' for channel in (0, 5): with (pytest.warns(PhotutilsDeprecationWarning), pytest.raises(ValueError, match=match)): load.load_irac_psf(channel) @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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning), 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) match = 'deprecated in version 3.1 .* removed in version 4.0' with (pytest.warns(PhotutilsDeprecationWarning, match=match), 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 (pytest.warns(PhotutilsDeprecationWarning), 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 (pytest.warns(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning), 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) match = 'deprecated in version 3.1 .* removed in version 4.0' with (pytest.warns(PhotutilsDeprecationWarning, match=match), 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 (pytest.warns(PhotutilsDeprecationWarning), 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(PhotutilsDeprecationWarning): result = load.get_path(fn, location='local') assert fn in result astropy-photutils-24be529/photutils/datasets/tests/test_model_params.py000066400000000000000000000144441526452531300267000ustar00rootroot00000000000000# 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)) with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=5) match = 'alpha must be a 2-tuple' with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=(1, 2), alpha=(1, 2, 3)) match = 'n_sources must be >= 0' with pytest.raises(ValueError, match=match): make_model_params(shape, -1, flux=(1, 2)) def test_make_model_params_meta(): """ Test that the output table contains version metadata. """ params = make_model_params((50, 50), 2, seed=0) assert 'date' in params.meta assert 'version' in params.meta 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_model_params_reserved_names(): """ Test that a keyword argument cannot replace the ID or position columns. """ shape = (100, 100) for name in ('id', 'x_0', 'y_0'): match = f'{name} cannot be used as a keyword argument' with pytest.raises(ValueError, match=match): make_model_params(shape, 5, **{name: (1, 2)}) match = 'x_mean cannot be used as a keyword argument' with pytest.raises(ValueError, match=match): make_model_params(shape, 5, x_name='x_mean', y_name='y_mean', x_mean=(1, 2)) # x_0 is an ordinary parameter name when x_name is different params = make_model_params(shape, 5, x_name='x_mean', y_name='y_mean', x_0=(1, 2)) assert params.colnames == ['id', 'x_mean', 'y_mean', 'x_0'] 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_make_random_models_table_inputs(): """ Test invalid inputs to ``make_random_models_table``. """ match = 'amplitude must be a 2-tuple' with pytest.raises(ValueError, match=match): make_random_models_table(3, {'amplitude': (1, 2, 3)}) with pytest.raises(ValueError, match=match): make_random_models_table(3, {'amplitude': 5}) match = 'n_sources must be >= 0' with pytest.raises(ValueError, match=match): make_random_models_table(-1, {'amplitude': (1, 2)}) match = "param_ranges cannot contain an 'id' key" with pytest.raises(ValueError, match=match): make_random_models_table(3, {'id': (1, 2)}) 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) match = 'model must be a Model instance' with pytest.raises(TypeError, match=match): params_table_to_models(tbl, None) astropy-photutils-24be529/photutils/datasets/tests/test_noise.py000066400000000000000000000064441526452531300253530ustar00rootroot00000000000000# 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_apply_poisson_noise_nonfinite(): """ Test if nonfinite image values raise ValueError. """ match = 'data must not contain any nonfinite values' for value in (np.nan, np.inf, -np.inf): data = np.array([[1.0, value], [2.0, 3.0]]) 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-24be529/photutils/datasets/tests/test_positional_kwargs.py000066400000000000000000000054251526452531300277730ustar00rootroot00000000000000# 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 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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) astropy-photutils-24be529/photutils/datasets/tests/test_thread_safety.py000066400000000000000000000073071526452531300270570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the datasets tools are safe for concurrent use. The functions are pure (they copy the input model, use only local random generators, and hold no module-level mutable state), so concurrent calls must produce results identical to serial calls and must not modify shared inputs. """ import threading from concurrent.futures import ThreadPoolExecutor import numpy as np from astropy.modeling.models import Moffat2D from astropy.table import QTable from numpy.testing import assert_equal from photutils.datasets import (make_model_image, make_model_params, make_noise_image) N_THREADS = 8 N_CALLS = 10 def _run_concurrently(task, n_threads=N_THREADS): """ Run ``task`` in ``n_threads`` threads and return all results. A barrier maximizes the overlap of the concurrent calls. """ barrier = threading.Barrier(n_threads) def worker(): barrier.wait() return [task() for _ in range(N_CALLS)] with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(worker) for _ in range(n_threads)] return [result for future in futures for result in future.result()] def test_concurrent_make_model_image(): """ Test that concurrent make_model_image calls sharing a single model instance and parameter table return results identical to a serial call and do not modify the shared model. """ params = QTable() params['x_0'] = [25.0, 50.0, 75.0] params['y_0'] = [30.0, 50.0, 70.0] params['gamma'] = [1.7, 2.32, 5.8] params['alpha'] = [2.9, 5.7, 4.6] model = Moffat2D(amplitude=1) model_params_orig = model.parameters.copy() shape = (100, 100) model_shape = (11, 11) expected = make_model_image(shape, model, params, model_shape=model_shape) def task(): return make_model_image(shape, model, params, model_shape=model_shape) for result in _run_concurrently(task): assert_equal(result, expected) assert_equal(model.parameters, model_params_orig) def test_concurrent_make_model_params(): """ Test that concurrent seeded make_model_params calls return results identical to a serial call. """ kwargs = {'flux': (100, 500), 'min_separation': 3, 'seed': 0} expected = make_model_params((100, 100), 5, **kwargs) def task(): return make_model_params((100, 100), 5, **kwargs) for result in _run_concurrently(task): assert_equal(result['x_0'], expected['x_0']) assert_equal(result['y_0'], expected['y_0']) assert_equal(result['flux'], expected['flux']) def test_concurrent_make_noise_image(): """ Test that concurrent seeded make_noise_image calls return results identical to a serial call. """ shape = (100, 100) kwargs = {'distribution': 'gaussian', 'mean': 0.0, 'stddev': 2.0, 'seed': 123} expected = make_noise_image(shape, **kwargs) def task(): return make_noise_image(shape, **kwargs) for result in _run_concurrently(task): assert_equal(result, expected) def test_concurrent_make_noise_image_unseeded_streams(): """ Test that concurrent unseeded make_noise_image calls use independent random streams (no shared global state). """ shape = (10, 10) def task(): return make_noise_image(shape, distribution='gaussian', mean=0.0, stddev=2.0) results = _run_concurrently(task) stacked = np.array(results) # All results should be distinct (independent entropy per call) assert len(np.unique(stacked.sum(axis=(1, 2)))) == len(results) astropy-photutils-24be529/photutils/datasets/tests/test_wcs.py000066400000000000000000000037031526452531300250250ustar00rootroot00000000000000# 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(): """ Test the basic functionality of make_wcs. """ shape = (100, 200) wcs = make_wcs(shape) assert wcs.pixel_shape == (200, 100) # (nx, ny) assert wcs.array_shape == shape assert wcs.wcs.radesys == 'ICRS' wcs = make_wcs(shape, galactic=True) assert wcs.wcs.ctype[0] == 'GLON-TAN' assert wcs.wcs.ctype[1] == 'GLAT-TAN' @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_make_gwcs(): """ Test the basic functionality of 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') @pytest.mark.parametrize('galactic', [False, True]) def test_make_wcs_compare(galactic): """ Test that make_wcs and make_gwcs return equivalent WCS transformations. The image is large so that the corners are far enough from the reference pixel to distinguish between projections. """ shape = (4000, 6000) wcs = make_wcs(shape, galactic=galactic) gwcs_obj = make_gwcs(shape, galactic=galactic) xpos = (0, 5999, 0, 5999, 3000) ypos = (0, 0, 3999, 3999, 2000) sc1 = wcs.pixel_to_world(xpos, ypos) sc2 = gwcs_obj.pixel_to_world(xpos, ypos) assert_allclose(sc1.separation(sc2).arcsec, 0, atol=1e-6) astropy-photutils-24be529/photutils/datasets/wcs.py000066400000000000000000000117421526452531300226260ustar00rootroot00000000000000# 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) [50. 50.] >>> print(wcs.wcs.crval) [197.8925 -1.36555556] >>> skycoord = wcs.pixel_to_world(42, 57) >>> print(skycoord) """ 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[::-1] # (nx, ny) 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-TAN', 'GLAT-TAN'] 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) """ 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-24be529/photutils/detection/000077500000000000000000000000001526452531300216215ustar00rootroot00000000000000astropy-photutils-24be529/photutils/detection/__init__.py000066400000000000000000000006341526452531300237350ustar00rootroot00000000000000# 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-24be529/photutils/detection/core.py000066400000000000000000001111331526452531300231230ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Base classes and the 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 from functools import cached_property import astropy.units as u import numpy as np from astropy.stats import gaussian_fwhm_to_sigma 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._parameters import validate_table_columns from photutils.utils._quantity_helpers import check_units from photutils.utils._repr import make_repr from photutils.utils._round import round_half_away from photutils.utils.cutouts import _make_cutouts from photutils.utils.exceptions import (NoDetectionsWarning, PhotutilsDeprecationWarning) __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 `~numpy.ndarray` or `~astropy.units.Quantity` 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.QTable` 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 `~numpy.ndarray` 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` by default 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 ``convolved_data``, where a `True` value indicates the corresponding element of ``convolved_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) integer pixel coordinates of the peak pixels. These are integer indices, not subpixel centroids. The star finder classes compute subpixel centroid positions separately from these peak positions. `None` is returned if no sources are found. Notes ----- Because the returned positions are the integer pixel coordinates of the peak pixels, the ``min_separation`` constraint applies to these integer positions. The star finder classes subsequently compute subpixel centroid positions (e.g., via marginal Gaussian fits), which may shift sources slightly. This means the final centroid-based separations in the star finder output tables can be slightly smaller (or larger) than ``min_separation``. """ # 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 `~numpy.ndarray` or `~astropy.units.Quantity` 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.QTable` 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: the object centroid in the x direction. * :attr:`y_centroid` property: the 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` or `~astropy.units.Quantity` 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. The positions are rounded to the nearest pixel. 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 # The cutouts are centered on the nearest pixel, so the positions # are rounded the same way to keep the cutout origins consistent self.xypos = round_half_away(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 = ('n_sources',) overrides = {'n_sources': len(self)} return make_repr(self, params, brackets=True, overrides=overrides) def __str__(self): params = ('n_sources',) overrides = {'n_sources': 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. Class-specific attributes to copy are declared by overriding the ``_get_init_attributes`` method in subclasses. """ # NOTE: we allow indexing/slicing of scalar (self.isscalar = True) # instances in order to perform catalog filtering even for # a single source # Normalize an integer index to a one-element fancy index so # that the leading source axis is always preserved, including # for cached multidimensional per-source arrays (e.g., moments # and cutouts). if (isinstance(index, (int, np.integer)) and not isinstance(index, bool)): index = [index] 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 attr = 'xypos' setattr(newcls, attr, getattr(self, attr)[index]) # Index/slice the remaining attributes keys = set(self.__dict__) & set(self._cached_properties) 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 newcls.__dict__[key] = value[index] return newcls def _get_init_attributes(self): """ Return a tuple of attribute names to copy during slicing. Subclasses that define additional attributes in ``__init__`` should override this method to include them. """ return ('data', 'unit', 'kernel', 'n_brightest', 'peak_max', 'cutout_shape', 'default_columns') @property def _cached_properties(self): """ Return all cached properties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_properties_cache' # Subclasses get their own cached-property list if attr not in cls.__dict__: def is_cached_property(obj): return isinstance(obj, cached_property) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=is_cached_property)]) return getattr(cls, attr) @property def _properties(self): """ A sorted list of the built-in source properties. """ cached_properties = [name for name in self._cached_properties if not name.startswith('_')] # isscalar is a scalar value for the whole object, not a # per-source value, so it is not a valid table column cached_properties.remove('isscalar') cached_properties.sort() return cached_properties @cached_property 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` or `~astropy.units.Quantity` The 2D image array. Any units are ignored. Returns ------- cutouts : 3D `~numpy.ndarray` or `~astropy.units.Quantity` The cutout arrays with shape ``(n_sources, ny, nx)``. The catalog ``unit`` is attached if it is not `None`. """ 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 @cached_property 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) @cached_property 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 @cached_property def moments_central(self): """ The central image moments up to second order, normalized by the zeroth raw moment (the total cutout flux). """ 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] @cached_property def cutout_centroid(self): """ The centroids within the cutouts as an Nx2 array in ``(y, x)`` order. """ 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)) @cached_property def cutout_x_centroid(self): """ The cutout x centroids. """ return np.transpose(self.cutout_centroid)[1] @cached_property 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') @cached_property def mu_sum(self): """ The sum of the second-order central moments in x and y. """ return (self.moments_central[:, 0, 2] + self.moments_central[:, 2, 0]) @cached_property def mu_diff(self): """ The difference of the second-order central moments in x and y (x minus y). """ return (self.moments_central[:, 0, 2] - self.moments_central[:, 2, 0]) @cached_property def fwhm(self): """ The FWHM of the sources. """ return 2.0 * np.sqrt(np.log(2.0) * self.mu_sum) @cached_property 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 counterclockwise direction and is in the range (-90, 90] degrees. """ angle = 0.5 * np.arctan2(2.0 * self.moments_central[:, 1, 1], self.mu_diff) return np.rad2deg(angle) * u.deg @cached_property def roundness(self): """ The roundness of the sources, computed from the second-order central moments. The value is 0 for a circular source and approaches 1 for a highly elongated source. """ 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) @cached_property def peak(self): """ The peak pixel values. """ return np.max(self.cutout_data, axis=(1, 2)) @cached_property def flux(self): """ The instrumental fluxes. """ return np.sum(self.cutout_data, axis=(1, 2)) @cached_property 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): """ Select the ``n_brightest`` sources with the largest fluxes. Returns ------- catalog : `StarFinderCatalogBase` A new catalog of the ``n_brightest`` sources sorted by decreasing flux. If ``n_brightest`` is `None`, the catalog itself is returned unchanged. The source IDs are not reset. """ 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 nonfinite values. Parameters ---------- attrs : tuple of str Attribute names to check for finiteness. initial_mask : 1D `~numpy.ndarray` of bool or `None`, optional An 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 attribute bounds and ``peak_max``. 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 An 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. Returns ------- catalog : `StarFinderCatalogBase` or `None` The filtered catalog, or `None` if no sources remain. """ def apply_all_filters(self): """ Apply all filters, select the brightest, and reset the source IDs. Returns ------- catalog : `StarFinderCatalogBase` or `None` The filtered catalog, or `None` if no sources pass the filters. """ 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 : str or list of str, optional Names of column(s) to include in the table. If `None`, uses ``self.default_columns``. The valid names are ``'id'``, the names in ``default_columns``, and the cached source properties. Returns ------- table : `~astropy.table.QTable` A table of the catalog properties. Raises ------ ValueError If any name in ``columns`` is not a valid (or deprecated) column name. """ # 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) table_columns = list(self.default_columns) else: # id is not included in self._properties because it is # not a cached property allowed_columns = (set(self._properties) | {'id'} | set(self.default_columns)) table_columns = validate_table_columns( columns, allowed_columns, deprecated_names=_DEPRECATED_ATTRIBUTES) for column in table_columns: values = getattr(self, column) # Use the canonical (nondeprecated) column name so that # assigning into ``table`` does not trigger a second, # redundant deprecation warning (the deprecated attribute # access above already warned once). canonical_column = _DEPRECATED_ATTRIBUTES.get(column, column) table[canonical_column] = values return table class _StarFinderKernel: """ Container class for a 2D Gaussian density enhancement kernel. By default, the kernel has negative wings and sums to zero. It is used by both `DAOStarFinder` and `IRAFStarFinder`. Parameters ---------- fwhm : float The full width at 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 counterclockwise 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. 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``. Notes ----- The kernel is built from 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} 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 or `None` 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 _validate_xycoords_bounds(xycoords, shape): """ Validate that ``xycoords`` positions are within the data shape. Positions are interpreted as pixel coordinates, where pixel centers are at integer values and the image footprint spans ``(-0.5, nx - 0.5)`` in x and ``(-0.5, ny - 0.5)`` in y. Parameters ---------- xycoords : Nx2 `~numpy.ndarray` The (x, y) pixel coordinates of sources. shape : tuple of 2 int The ``(ny, nx)`` shape of the data array. Raises ------ ValueError If any position is outside the bounds of the data. """ ny, nx = shape xpos = xycoords[:, 0] ypos = xycoords[:, 1] if np.any((xpos <= -0.5) | (xpos >= nx - 0.5) | (ypos <= -0.5) | (ypos >= ny - 0.5)): msg = ('xycoords must be within the bounds of the input data ' f'with shape {tuple(shape)}') raise ValueError(msg) 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 range used to fill in a bound that is not given by the deprecated parameters 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 version 4.0. ' f"Use '{new_name}=(lower, upper)' instead.") warnings.warn(msg, PhotutilsDeprecationWarning) _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-24be529/photutils/detection/daofinder.py000066400000000000000000001313111526452531300241260ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ DAOStarFinder class. """ import warnings from functools import cached_property import astropy.units as u import numpy as np from photutils.detection.core import (_DEPR_DEFAULT, StarFinderBase, StarFinderCatalogBase, _handle_deprecated_range, _StarFinderKernel, _validate_n_brightest, _validate_xycoords_bounds) 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 algorithm (Stetson 1987 [1]_). DAOFIND searches images for local density maxima that have a peak amplitude greater than ``threshold`` (approximately, since ``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. The kernel has a positive core and negative wings, and it sums to zero. Convolving the image with this kernel is equivalent to a linear least-squares fit, at every pixel, of the amplitude of the Gaussian centered on that pixel plus a constant local background. Each pixel of the convolved image is therefore the height of the best-fitting Gaussian at that position, in the units of the input ``data``. Because the local background is fitted, the convolved image does not depend on the background level. For uncorrelated noise, the uncertainty of the fitted height is the pixel noise multiplied by the kernel relative error, which is why ``threshold`` is scaled by that factor by default (see ``scale_threshold``). The local background is fitted using only the pixels within the kernel footprint, so a larger ``sigma_radius`` gives a less noisy fitted height. ``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) is the difference between the heights of the best-fitting Gaussian functions in x and y, divided by the average of the two heights. 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 is the ratio of the difference between the height of the central pixel and the mean of the surrounding pixels within the kernel footprint in the input (unconvolved) image, to the height of the best-fitting Gaussian function at that point (the central pixel value of the convolved image). 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 at 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 counterclockwise from the positive x-axis. The default is 0.0. 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)})`). The default is 1.5. sharplo : float, optional The lower bound on sharpness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.0. Use ``sharpness_range=(lower, upper)`` instead. sharphi : float, optional The upper bound on sharpness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.0. Use ``sharpness_range=(lower, upper)`` instead. roundlo : float, optional The lower bound on roundness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.0. Use ``roundness_range=(lower, upper)`` instead. roundhi : float, optional The upper bound on roundness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.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 array_like, 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, and ``threshold``, ``min_separation``, ``exclude_border``, and the ``mask`` input to ``find_stars`` are not used to select the sources. The positions are rounded to the nearest pixel and must be within the bounds of the input ``data``. 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``. This is not the DAOFIND default, which is ``boundary='nearest'``. The two modes differ only for sources near the image borders. With the zero boundary used here, a star that is truncated by the image border can be detected. Its centroid is biased toward the image interior and its sharpness and roundness values are not reliable. With the DAOFIND default, the centroid of such a star usually falls outside of the image and the star is rejected. Set ``exclude_border=True`` to exclude the sources near the image borders. As in DAOFIND, the centroid is computed using the major-axis standard deviation of the Gaussian kernel for the x-axis fit and the minor-axis standard deviation for the y-axis fit, whatever the kernel ``theta``. The centroids are therefore biased for an elliptical kernel (``ratio < 1``) whose major axis is not along the x-axis. The largest bias occurs for a ``theta`` of 90 or 270 degrees, where the x offset of the centroid from the peak pixel is too large by a factor of ``1 / ratio**2`` and the y offset is too small by the same factor. Circular kernels and elliptical kernels with a ``theta`` of 0 or 180 degrees are not affected. 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. This routine differs from DAOFIND for pixels of the convolved image that have identical values, which are uncommon for floating-point data (a saturated star usually has none). For a positive ``min_separation``, such pixels that are within ``min_separation`` of each other give only one source (see `~photutils.detection.find_peaks`). DAOFIND keeps every pixel that is tied for the maximum within the kernel footprint, except for those that follow a detected pixel within half the kernel width along the same image line. With ``min_separation=0``, this routine keeps every pixel that is tied for the maximum within the kernel footprint. 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: # _find_stars returns the integer peak pixel positions. # The subpixel centroids are computed later in the catalog. xypos = self._find_stars(convolved_data, self.kernel, self.threshold_eff, mask=mask, min_separation=self.min_separation, exclude_border=self.exclude_border) else: _validate_xycoords_bounds(self.xycoords, data.shape) 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 `~numpy.ndarray` or `~astropy.units.Quantity` 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 value of the input data at the central pixel 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 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)``. 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. scale_threshold : bool, optional If `True` (default), the input ``threshold`` is multiplied by the kernel relative error to compute the effective threshold used by ``daofind_mag``. If `False`, the input ``threshold`` is used directly. """ 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') @cached_property def cutout_convdata(self): """ The cutout of the convolved data centered on the source position. """ return self.make_cutouts(self.convolved_data) @cached_property def peak(self): """ The value of the original (unconvolved) data at the central pixel of each source cutout. """ return self.cutout_data[:, self.cutout_center[0], self.cutout_center[1]] @cached_property def flux(self): """ The instrumental fluxes, calculated as the sum of the unconvolved data values within the kernel footprint. """ return np.sum(self.cutout_data * self.kernel.mask, axis=(1, 2)) @cached_property def convdata_peak(self): """ The value of the convolved data at the central pixel of each source cutout. """ return self.cutout_convdata[:, self.cutout_center[0], self.cutout_center[1]] @cached_property 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. As in DAOFIND, the symmetry is measured over the central square of the convolved-data cutout. For a non-square kernel, the pixels beyond the smaller kernel half-width are not used. """ # DAOFIND computes the sums over the central square of the # cutout, with a half-width equal to the smaller of the two # cutout half-widths. This is the full cutout for a square # kernel. ycen, xcen = self.cutout_center center = min(ycen, xcen) cutout_conv = self.cutout_convdata[ :, ycen - center:ycen + center + 1, xcen - center:xcen + center + 1].copy() # Set the central (peak) pixel to zero for the sum4 calculation cutout_conv[:, center, center] = 0.0 # Calculate the four roundness quadrants of the central square, # 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:center + 1, center + 1:] quad2 = cutout_conv[:, 0:center, 0:center + 1] quad3 = cutout_conv[:, center:, 0:center] quad4 = cutout_conv[:, center + 1:, center:] 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 @cached_property 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 pixels within the kernel mask in the unconvolved data, to the central pixel value of the convolved data. """ # 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 used for the given axis. As in DAOFIND, this is the major-axis value for the x-axis and the minor-axis value for the y-axis, whatever the kernel ``theta``. dxx : 1D `~numpy.ndarray` The array of pixel offsets from the center pixel along the given axis. As in DAOFIND, the offsets are the pixel index minus the center for the y-axis and the center minus the pixel index for the x-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 (the kernel weighted by the 2D weighting function and summed over the perpendicular axis). * ``dkern_dx``: the derivative of the 1D kernel distribution (the 1D kernel distribution multiplied by the pixel offsets from the center). * ``dkern_dx_sum``: the sum of the derivative of the 1D kernel distribution weighted by the 1D weighting function. * ``dkern_dx2_sum``: the sum of the square of the derivative of the 1D kernel distribution weighted by the 1D weighting function. * ``kern_dkern_dx_sum``: the sum of the product of the 1D kernel distribution and its derivative, weighted by the 1D 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 1D weighting function. * ``data_dx_sum``: the sum of the 1D data distribution weighted by the pixel offsets and the 1D 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 used for the given axis (see ``_marginal_weights``). 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 to 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 ------- result : Nx2 `~numpy.ndarray` An array where each row contains the fractional shift (dx) of the image centroid relative to the central pixel and the height (hx) of the best-fitting Gaussian to the marginal distribution of the unconvolved source data, along x or y depending on ``axis``. Rejected fits are NaN. """ 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) @cached_property 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) @cached_property 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) @cached_property 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] @cached_property 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] @cached_property 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] @cached_property 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] @cached_property 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 @cached_property 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 @cached_property def roundness2(self): """ The star roundness. This roundness parameter is the difference between the heights of the best-fitting Gaussian functions in x and y, divided by the average of the two heights. 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) @cached_property 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] @cached_property 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) @cached_property 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') newcat = self._filter_finite(attrs) if newcat is None: return None # As in DAOFIND, reject sources with a centroid outside the image ny, nx = newcat.data.shape mask = ((newcat.x_centroid >= -0.5) & (newcat.x_centroid <= nx - 0.5) & (newcat.y_centroid >= -0.5) & (newcat.y_centroid <= ny - 0.5)) bounds = [ ('sharpness', self.sharpness_range), ('roundness1', self.roundness_range), ('roundness2', self.roundness_range), ] return newcat._filter_bounds(bounds, initial_mask=mask) astropy-photutils-24be529/photutils/detection/irafstarfinder.py000066400000000000000000000636361526452531300252140ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ IRAFStarFinder class. """ import warnings from functools import cached_property import numpy as np from photutils.detection.core import (_DEPR_DEFAULT, StarFinderBase, StarFinderCatalogBase, _handle_deprecated_range, _StarFinderKernel, _validate_n_brightest, _validate_xycoords_bounds) 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, PhotutilsDeprecationWarning) __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. The image is convolved with a circular Gaussian kernel that has a positive core and negative wings, and that sums to zero. This convolution is equivalent to a linear least-squares fit, at every pixel, of the amplitude of the Gaussian centered on that pixel plus a constant local background. Each pixel of the convolved image is therefore the height of the best-fitting Gaussian at that position, in the units of the input ``data``. Because the local background is fitted, the convolved image does not depend on the background level. The ``threshold`` is applied directly to this fitted height. The local background is fitted using only the pixels within the kernel footprint, so a larger ``sigma_radius`` gives a less noisy fitted height. 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 at 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. This value is ignored if ``min_separation`` is input. Note that large values may result in long run times. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.0. Use ``min_separation`` instead. sharplo : float, optional The lower bound on sharpness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.0. Use ``sharpness_range=(lower, upper)`` instead. sharphi : float, optional The upper bound on sharpness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.0. Use ``sharpness_range=(lower, upper)`` instead. roundlo : float, optional The lower bound on roundness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.0. Use ``roundness_range=(lower, upper)`` instead. roundhi : float, optional The upper bound on roundness for object detection. .. deprecated:: 3.0 This keyword is deprecated and will be removed in version 4.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. The peak value is measured after the local sky is subtracted. 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 array_like, 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, and ``threshold``, ``min_separation``, ``exclude_border``, and the ``mask`` input to ``find_stars`` are not used to select the sources. The positions are rounded to the nearest pixel and must be within the bounds of the input ``data``. 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``. If 0, a local peak is required only within the kernel footprint. 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)``. min_n_pixels : int, optional The minimum number of positive sky-subtracted pixels within the kernel footprint that a source must have. Sources with fewer such pixels are rejected. The default is 5, which is the default and the minimum value of the ``npixmin`` parameter in IRAF's starfind. 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))`` * ``min_n_pixels = npixmin`` As in IRAF's starfind, sources whose centroid is outside of the image are rejected. 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` internally calculates a local "sky" background level for each source as the mean of the pixels in the kernel array that lie outside the kernel footprint, and subtracts it before computing the source properties. If the kernel footprint covers the entire kernel array, the sky is the central data value minus the central value of the convolved data. * `~photutils.detection.IRAFStarFinder` calculates the objects' centroid, roundness, and sharpness using image moments. This routine differs from IRAF's starfind for pixels of the convolved image that have identical values, which are uncommon for floating-point data (a saturated star usually has none). For a positive ``min_separation``, such pixels that are within ``min_separation`` of each other give only one source (see `~photutils.detection.find_peaks`). IRAF's starfind keeps every pixel that is tied for the maximum within ``sepmin``, except for those that follow a detected pixel within ``sepmin`` along the same image line. With ``min_separation=0``, this routine keeps every pixel that is tied for the maximum within the kernel footprint. """ @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), min_n_pixels=5): # 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 version 4.0. Use ' "'min_separation' instead.") warnings.warn(msg, PhotutilsDeprecationWarning) 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 min_n_pixels < 1: msg = 'min_n_pixels must be >= 1' raise ValueError(msg) self.min_n_pixels = min_n_pixels 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', 'min_n_pixels') 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: # _find_stars returns the integer peak pixel positions. # The subpixel centroids are computed later in the catalog. xypos = self._find_stars(convolved_data, self.kernel, self.threshold, min_separation=self.min_separation, mask=mask, exclude_border=self.exclude_border) else: _validate_xycoords_bounds(self.xycoords, data.shape) 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, min_n_pixels=self.min_n_pixels) @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 `~numpy.ndarray` or `~astropy.units.Quantity` 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 of the source, measured counterclockwise, in the range (-90, 90] degrees. * ``n_pixels``: the total number of (positive) unmasked pixels. * ``peak``: the peak sky-subtracted pixel value of the object within the kernel footprint. * ``flux``: the object instrumental flux calculated as the sum of the positive 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 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)``. 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. min_n_pixels : int, optional The minimum number of positive sky-subtracted pixels within the kernel footprint that a source must have. The default is 5. """ def __init__(self, data, convolved_data, xypos, kernel, *, sharpness_range=(0.5, 2.0), roundness_range=(0.0, 0.2), n_brightest=None, peak_max=None, min_n_pixels=5): # 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.min_n_pixels = min_n_pixels 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', 'min_n_pixels', 'cutout_shape', 'default_columns') @cached_property def sky(self): """ Calculate the sky background level. The local sky level is roughly estimated using the IRAF starfind calculation as the average value of the pixels in the kernel array that are outside the kernel footprint. If there are no such pixels, the sky is the central data value minus the central convolved data value. """ skymask = ~self.kernel.mask.astype(bool) # True=sky, False=obj n_sky = np.count_nonzero(skymask) if n_sky == 0: # The kernel footprint covers the entire kernel array, so # there are no sky pixels. As in IRAF starfind, the sky is # then the central data value minus the central convolved # data value (the best-fitting Gaussian amplitude). ycen, xcen = (size // 2 for size in self.cutout_shape) cutout_conv = self.make_cutouts(self.convolved_data) return (self.cutout_data_nosub[:, ycen, xcen] - cutout_conv[:, ycen, xcen]) axis = (1, 2) sky = np.sum(self.cutout_data_nosub * skymask, axis=axis) / n_sky if self.unit is not None: sky <<= self.unit return sky @cached_property def cutout_data_nosub(self): """ The cutout data without sky subtraction or masking. """ return self.make_cutouts(self.data) @cached_property 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 @cached_property 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)) @cached_property def cutout_xorigin(self): """ The x pixel coordinate of the cutout origin. """ return np.transpose(self.xypos)[0] - self.kernel.x_radius @cached_property def cutout_yorigin(self): """ The y pixel coordinate of the cutout origin. """ return np.transpose(self.xypos)[1] - self.kernel.y_radius @cached_property def x_centroid(self): """ The x pixel coordinate of the object centroid. """ return self.cutout_x_centroid + self.cutout_xorigin @cached_property def y_centroid(self): """ The y pixel coordinate of the object centroid. """ return self.cutout_y_centroid + self.cutout_yorigin @cached_property 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 = self.n_pixels >= self.min_n_pixels newcat = self._filter_finite(attrs, initial_mask=initial_mask) if newcat is None: return None bounds = [ ('sharpness', self.sharpness_range), ('roundness', self.roundness_range), ] # As in IRAF starfind, reject sources with a centroid outside the # image ny, nx = newcat.data.shape mask = ((newcat.x_centroid >= -0.5) & (newcat.x_centroid <= nx - 0.5) & (newcat.y_centroid >= -0.5) & (newcat.y_centroid <= ny - 0.5)) return newcat._filter_bounds(bounds, initial_mask=mask) astropy-photutils-24be529/photutils/detection/peakfinder.py000066400000000000000000000761241526452531300243150ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for finding local peaks in an astronomical image. """ import inspect import warnings import numpy as np from astropy.table import QTable from scipy.ndimage import label, maximum_filter from scipy.spatial import cKDTree 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'] # The radius in pixels below which the circular footprint filter is # faster than the box filters with circular verification _MIN_FAST_RADIUS = 4.0 # The mean number of too-close pairs per equal-valued peak above which # the neighbors of only the kept peaks are found, instead of all the # pairs at once _MAX_PAIRS_PER_PEAK = 4 # The number of equal-valued peaks used to estimate the mean number of # too-close pairs per peak _N_SAMPLE_PEAKS = 1000 def _collapse_plateaus(peak_mask, min_separation): """ Find the peak pixels, with each plateau reduced to a single pixel. A peak pixel is the maximum within the circle of radius ``min_separation`` centered on it, so connected peak pixels always have equal values (e.g., the pixels of a saturated star). Each group of connected peak pixels is replaced by its pixel that is nearest to the centroid of the group. For equal distances, the first pixel in row-major order is kept. Parameters ---------- peak_mask : 2D bool `~numpy.ndarray` The boolean mask of peak pixels. It is not modified. min_separation : float The radius in pixels of the circular region that was used to find the peaks. Returns ------- y_peaks, x_peaks : 1D `~numpy.ndarray` The pixel coordinates of the peaks, in row-major order (ascending y, then x). n_pixels : 1D `~numpy.ndarray` or `None` The number of pixels in the plateau of each peak. `None` is returned if there are no plateaus. """ if min_separation < 1: # Adjacent pixels are outside of each other's circular region, # so they are separate peaks that can have different values return *peak_mask.nonzero(), None # Diagonal neighbors are within the circular region, and therefore # are equal valued, only if the radius is at least sqrt(2) pixels use_diagonals = min_separation**2 >= 2 # Checking for adjacent peak pixels is much faster than labeling # the peak pixels, and most images have no plateaus has_plateau = (np.any(peak_mask[:, 1:] & peak_mask[:, :-1]) or np.any(peak_mask[1:] & peak_mask[:-1])) if not has_plateau and use_diagonals: has_plateau = (np.any(peak_mask[1:, 1:] & peak_mask[:-1, :-1]) or np.any(peak_mask[1:, :-1] & peak_mask[:-1, 1:])) if not has_plateau: return *peak_mask.nonzero(), None structure = np.ones((3, 3)) if use_diagonals else None labels, n_labels = label(peak_mask, structure=structure) y_peaks, x_peaks = peak_mask.nonzero() idx = labels[y_peaks, x_peaks] - 1 counts = np.bincount(idx, minlength=n_labels) # The squared distance of a pixel from the centroid of its label # is (y**2 + x**2) - 2 * (y * y_sum + x * x_sum) / count plus a # constant. It is multiplied by the count and calculated with # integers so that equal distances compare as equal. The sums are # integer valued and are exact as floats. y_sum = np.bincount(idx, weights=y_peaks, minlength=n_labels) x_sum = np.bincount(idx, weights=x_peaks, minlength=n_labels) y_sum = y_sum.astype(np.int64)[idx] x_sum = x_sum.astype(np.int64)[idx] y_pix = y_peaks.astype(np.int64) x_pix = x_peaks.astype(np.int64) dist = (counts[idx] * (y_pix**2 + x_pix**2) - 2 * (y_pix * y_sum + x_pix * x_sum)) # Sort by label and then by the distance from the label centroid. # The first pixel of each label is then the one to keep. order = np.lexsort((dist, idx)) first = np.ones(len(order), dtype=bool) first[1:] = idx[order[1:]] != idx[order[:-1]] keep = np.sort(order[first]) # row-major order return y_peaks[keep], x_peaks[keep], counts[idx[keep]] def _remove_tied_peaks(x_peaks, y_peaks, peak_values, min_separation, n_pixels=None): """ Remove equal-valued peaks that are too close to each other. The input peaks must each be the maximum within the circle of radius ``min_separation`` centered on it. Two such peaks can be separated by ``min_separation`` or less only if they have equal values, so only the peaks with a repeated value are searched. This function is therefore not valid for peaks found in any other way. Of the equal-valued peaks that are too close to each other, the peak of the larger plateau is kept, so that a saturated star is preferred over a nearby isolated saturated pixel. For equal-sized plateaus, the first peak in row-major order (ascending y, then x) is kept. The peaks are selected greedily in that order, so a peak is removed only if it is too close to a peak that is kept. Parameters ---------- x_peaks : 1D `~numpy.ndarray` The x pixel coordinates of the peaks. y_peaks : 1D `~numpy.ndarray` The y pixel coordinates of the peaks. peak_values : 1D `~numpy.ndarray` The peak values. min_separation : float The minimum separation (in pixels) between peaks. Peaks separated by exactly this distance are too close, which matches the circular region used to find the peaks. n_pixels : 1D `~numpy.ndarray` or `None`, optional The number of pixels in the plateau of each peak. If `None`, every peak is a single pixel. Returns ------- x_peaks, y_peaks, peak_values : tuple of `~numpy.ndarray` The filtered arrays, in the same row-major order (ascending y, then x) as the input arrays. """ # Sorting the values is much faster than finding the index of the # unique values, and most images have no repeated peak values sorted_values = np.sort(peak_values) if not np.any(sorted_values[1:] == sorted_values[:-1]): return x_peaks, y_peaks, peak_values _, inverse, counts = np.unique(peak_values, return_inverse=True, return_counts=True) repeated = np.nonzero(counts[inverse] > 1)[0] # Sort by descending intensity and then by descending plateau size # (the stable sort preserves the row-major order for ties). The # index of the unique values is used as the intensity rank because # unsigned integer values cannot be negated. if n_pixels is None: n_pixels = np.ones(len(x_peaks), dtype=int) order = repeated[np.lexsort((-n_pixels[repeated], -inverse[repeated]))] coords = np.column_stack((x_peaks[order], y_peaks[order])) # An unbalanced tree is much faster to build, and is as fast to # query, for peaks that are spread over the image tree = cKDTree(coords, balanced_tree=False, compact_nodes=False) # Estimate the mean number of pairs per peak from a sample of the # peaks, which is much faster than counting all the pairs. The # count for a peak includes the peak itself, and each pair is # counted from both of its peaks. n_peaks = len(order) step = max(1, n_peaks // _N_SAMPLE_PEAKS) n_close = tree.query_ball_point(coords[::step], min_separation, return_length=True) pairs_per_peak = (np.mean(n_close) - 1) / 2 keep = np.ones(n_peaks, dtype=bool) if pairs_per_peak <= _MAX_PAIRS_PER_PEAK: # Each row is a pair of peaks (i, j) with i < j pairs = tree.query_pairs(min_separation, output_type='ndarray') if len(pairs) == 0: return x_peaks, y_peaks, peak_values pairs = pairs[np.argsort(pairs[:, 0], kind='stable')] first, starts = np.unique(pairs[:, 0], return_index=True) neighbors = np.split(pairs[:, 1], starts[1:]) for i, later in zip(first, neighbors, strict=True): if keep[i]: keep[later] = False else: # With many pairs per peak, the array of all the pairs can need # more memory than the image, but few peaks are kept. Finding # the neighbors of only the kept peaks is then faster and needs # little memory. for i in range(n_peaks): if keep[i]: neighbors = np.array(tree.query_ball_point(coords[i], min_separation)) keep[neighbors[neighbors > i]] = False good = np.ones(len(x_peaks), dtype=bool) good[order[~keep]] = False return x_peaks[good], y_peaks[good], peak_values[good] 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 _circular_footprint(radius): """ Make a circular footprint centered on a pixel. Parameters ---------- radius : float The radius of the circular region in pixels. Returns ------- footprint : 2D bool `~numpy.ndarray` A square boolean array with an odd size along each axis. `True` indicates a pixel whose center is within ``radius`` pixels of the central pixel. """ half = int(radius) idx = np.arange(-half, half + 1) xx, yy = np.meshgrid(idx, idx) return (xx ** 2 + yy ** 2) <= radius ** 2 def _fast_circular_peaks(data, radius): """ Find pixels that are local maxima within circular regions. This is equivalent to:: footprint = _circular_footprint(radius) data_max = maximum_filter(data, footprint=footprint, mode='constant', cval=0.0) peaks = (data == data_max) For a radius of at least ``_MIN_FAST_RADIUS`` pixels, the result is computed with fast separable box filters and targeted circular verification, which is much faster for large radii. Smaller radii use the footprint filter directly because it is faster for them. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image array. Must be NaN-free because comparisons with NaN are always `False`, 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. """ footprint_bool = _circular_footprint(radius) if radius < _MIN_FAST_RADIUS: data_max = maximum_filter(data, footprint=footprint_bool, mode='constant', cval=0.0) return data == data_max footprint_size = footprint_bool.shape[0] 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: the largest centered box that fits inside the # circle half_side = int(radius / np.sqrt(2)) side_insc = 2 * half_side + 1 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 finds local maxima within a circular region of the given radius. Pixels with identical intensities are then reduced so that all returned peaks are separated by more than ``min_separation`` pixels. 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)``. If ``box_size`` has two elements, they must be in ``(ny, nx)`` order. ``box_size`` must have odd values for both axes if ``centroid_func`` is input. 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. Masked pixels can neither be detected as peaks nor suppress nearby peaks within the local region (they are treated the same as NaN pixels). .. versionchanged:: 3.1 Masked pixels no longer suppress nearby peaks within the local region. Previously, a masked pixel with a value larger than a nearby true peak prevented that peak from being detected. 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 nonnegative integers. n_peaks : int or `numpy.inf`, optional The maximum number of peaks to return. It must be a positive integer or `numpy.inf` (the default, meaning all peaks are returned). 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. When set to a positive value, a fast algorithm first identifies the local maxima within a circular region of this radius centered on each pixel. A group of connected peak pixels, which have identical values (e.g., a saturated star), is then replaced by its pixel nearest to the centroid of the group. Finally, only one of any remaining equal-valued peaks that are separated by ``min_separation`` or less is kept (see Notes). All returned peaks are therefore separated by more than ``min_separation`` pixels. Peaks separated by exactly ``min_separation`` are too close. This is much faster for large radii than using an explicit circular ``footprint`` of the given radius. When set to a positive value, ``box_size`` and ``footprint`` are not used for peak detection. If `None` (default) or 0, the peak detection uses ``box_size`` or ``footprint`` as specified. To return every pixel that is tied for the maximum, use a circular ``footprint`` instead. .. versionchanged:: 3.1 Pixels with identical values that are within ``min_separation`` of each other now give only one peak. Previously, every pixel that was tied for the maximum was returned. centroid_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The ``centroid_func`` must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword, and optionally an ``error`` keyword. The callable object must return two scalar values representing the (x, y) centroid. 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 the ``centroid_func`` does not accept an ``error`` keyword, the ``error`` array is ignored. 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.QTable`. Returns ------- output : `~astropy.table.QTable` or `None` A table containing the x and y pixel location of the peaks and their values, in the ``id``, ``x_peak``, ``y_peak``, and ``peak_value`` columns. The table also contains a ``skycoord_peak`` column if ``wcs`` is input, ``x_centroid`` and ``y_centroid`` columns if ``centroid_func`` is input, and a ``skycoord_centroid`` column if both are input. 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 in three steps. First, the local maxima within a circular ``footprint`` of the given radius, centered on each pixel, are identified. For a radius of at least 4 pixels, 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. Smaller radii use the footprint filter directly. The local-maximum step returns every pixel that is tied for the maximum, so the next two steps handle pixels with identical values. Second, each group of connected peak pixels (a plateau, e.g., the pixels of a saturated star) is replaced by its pixel nearest to the centroid of the group. A plateau therefore gives one peak, even if it is larger than ``min_separation``. Adjacent peak pixels are connected only if they are within ``min_separation`` of each other. Diagonal neighbors are therefore connected only for a ``min_separation`` of at least ``sqrt(2)`` pixels, and this step is skipped for a ``min_separation`` smaller than 1 pixel, for which every pixel above the threshold is a peak. Third, for any remaining equal-valued peaks that are separated by ``min_separation`` or less, only one is kept. This includes the peak of a plateau, which can be removed in favor of a nearby equal-valued peak. The peak of the plateau with the most pixels is kept, so that a saturated star is preferred over a nearby isolated saturated pixel. For plateaus of equal size, including single pixels, the first peak in row-major order (ascending y, then x) is kept. 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 is determined by ``footprint`` if it is input and otherwise 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. Masked pixels and any NaN values in the input ``data`` are replaced with the minimum non-NaN data value before peak detection, so they can neither be detected as peaks nor suppress nearby peaks. NaN 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 data.size == 0: msg = 'data must not be empty' raise ValueError(msg) 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 n_peaks != np.inf: if isinstance(n_peaks, bool): msg = 'n_peaks must be an integer' raise TypeError(msg) if n_peaks <= 0: msg = 'n_peaks must be > 0' raise ValueError(msg) n_peaks_int = int(n_peaks) if n_peaks_int != n_peaks: msg = 'n_peaks must be an integer' raise ValueError(msg) n_peaks = n_peaks_int 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) # Combine the input mask with a mask of NaN pixels. Masked and # NaN pixels are replaced by the minimum finite data value before # peak detection so that they can neither be detected as peaks nor # suppress nearby peaks within the local region. nan_mask = np.isnan(data) 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) mask = mask | nan_mask elif np.any(nan_mask): mask = nan_mask if mask is not None and np.any(mask): data = np.copy(data) # do not mutate the input data data[mask] = nanmin(data) # peak_goodmask: good pixels are True use_min_separation = min_separation is not None and min_separation > 0 if use_min_separation: 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: 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)) # The circular local maxima include every pixel that is tied for # the maximum. Reduce each plateau of connected peak pixels to one # pixel, then remove the remaining equal-valued peaks that are # still too close to each other. if use_min_separation: y_peaks, x_peaks, n_pixels = _collapse_plateaus(peak_goodmask, min_separation) peak_values = data[y_peaks, x_peaks] x_peaks, y_peaks, peak_values = _remove_tied_peaks( x_peaks, y_peaks, peak_values, min_separation, n_pixels=n_pixels) else: 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 # Pass the error array only to centroid functions that accept # an error keyword. Otherwise, it is ignored. centroid_kwargs = {} if error is not None: spec = inspect.signature(centroid_func) if ('error' in spec.parameters or any(param.kind == inspect.Parameter.VAR_KEYWORD for param in spec.parameters.values())): centroid_kwargs['error'] = error x_centroids, y_centroids = centroid_sources( data, x_peaks, y_peaks, box_size=centroid_box_size, footprint=footprint, mask=mask, centroid_func=centroid_func, **centroid_kwargs) 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-24be529/photutils/detection/starfinder.py000066400000000000000000000277651526452531300243550ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ StarFinder class. """ import warnings from functools import cached_property import numpy as np 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. The kernel values must be finite and its maximum value must be positive. 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)``. If 0, a local peak is required only within a region with the shape of the kernel. 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 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) if not np.all(np.isfinite(kernel)): msg = 'kernel must contain only finite values' raise ValueError(msg) if np.max(kernel) <= 0: msg = 'kernel must have a positive maximum value' 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) # _find_stars returns the integer peak pixel positions. # The subpixel centroids are computed later in the catalog. 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 `~numpy.ndarray` or `~astropy.units.Quantity` 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 sources with the following parameters: * ``id``: unique source identification number. * ``x_centroid, y_centroid``: source centroid. * ``fwhm``: source FWHM. * ``roundness``: source roundness. * ``orientation``: the angle between the x-axis and the major axis of the source, measured counterclockwise, in the range (-90, 90] degrees. * ``max_value``: the maximum pixel value in the source. * ``flux``: the source instrumental flux calculated as the sum of the nonnegative data values within a cutout with the shape of the kernel. * ``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`` criterion. """ # 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') @cached_property 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 @cached_property def max_value(self): """ The maximum pixel value in the cutout data. """ return self.peak @cached_property 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 @cached_property 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-24be529/photutils/detection/tests/000077500000000000000000000000001526452531300227635ustar00rootroot00000000000000astropy-photutils-24be529/photutils/detection/tests/__init__.py000066400000000000000000000000001526452531300250620ustar00rootroot00000000000000astropy-photutils-24be529/photutils/detection/tests/conftest.py000066400000000000000000000023711526452531300251650ustar00rootroot00000000000000# 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) # Convert sigma=1.5 to 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-24be529/photutils/detection/tests/test_core.py000066400000000000000000000567461526452531300253460ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photutils.detection.core module. """ import threading from concurrent.futures import ThreadPoolExecutor from functools import cached_property import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.detection import DAOStarFinder from photutils.detection.core import (_DEPR_DEFAULT, StarFinderCatalogBase, _StarFinderKernel, _validate_n_brightest) from photutils.utils.exceptions import PhotutilsDeprecationWarning 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 nonscalar 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 nonpositive 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 noninteger 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): @cached_property def x_centroid(self): return self.cutout_x_centroid @cached_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() @pytest.fixture(name='make_catalog') def fixture_make_catalog(minimal_catalog_cls): """ Factory fixture for minimal catalogs with common single-source (11x11 image) and three-source (21x21 image) layouts. """ def _make(n_sources=1, **kwargs): kernel = np.ones((3, 3)) if n_sources == 1: data = np.zeros((11, 11)) data[5, 5] = 10.0 xypos = np.array([[5, 5]]) else: data = np.zeros((21, 21)) data[5, 5] = 10.0 data[10, 10] = 50.0 data[15, 15] = 30.0 xypos = np.array([[5, 5], [10, 10], [15, 15]]) return minimal_catalog_cls(data, xypos, kernel, **kwargs) return _make class TestStarFinderCatalogBase: """ Tests for the StarFinderCatalogBase base-class methods. """ def test_get_init_attributes(self, make_catalog): """ Test base _get_init_attributes returns expected tuple. """ cat = make_catalog() expected = ('data', 'unit', 'kernel', 'n_brightest', 'peak_max', 'cutout_shape', 'default_columns') assert cat._get_init_attributes() == expected def test_cached_properties_class_cache(self, make_catalog): """ Test that _cached_properties is cached on the class and shared across instances. """ cat1 = make_catalog() cat2 = make_catalog() result1 = cat1._cached_properties result2 = cat2._cached_properties assert result1 is result2 def test_to_table_missing_default_columns(self, make_catalog): """ Test that to_table raises when default_columns is not set. """ cat = make_catalog() match = 'default_columns attribute is not set' with pytest.raises(AttributeError, match=match): cat.to_table() def test_to_table_explicit_columns(self, make_catalog): """ Test that to_table works with explicit column names. """ cat = make_catalog() columns = ('id', 'x_centroid', 'y_centroid') tbl = cat.to_table(columns=columns) assert len(tbl) == 1 assert tbl.colnames == list(columns) def test_to_table_invalid_column(self, make_catalog): """ Regression test for invalid column names not being validated in to_table (e.g., ``data`` is an attribute, but not a valid column name). """ cat = make_catalog() match = 'Invalid column name' with pytest.raises(ValueError, match=match): cat.to_table(columns=('id', 'data')) def test_to_table_scalar_column(self, make_catalog): """ Regression test to ensure that ``isscalar`` (a scalar value for the whole object, not a per-source value) is not a valid ``to_table`` column. """ cat = make_catalog() match = 'Invalid column name' with pytest.raises(ValueError, match=match): cat.to_table(columns='isscalar') def test_to_table_deprecated_column(self, make_catalog): """ Regression test to ensure that deprecated column names are still accepted by ``to_table``, not rejected by the new column-name validation. """ cat = make_catalog() match = "'xcentroid' attribute was deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match): tbl = cat.to_table(columns='xcentroid') assert tbl.colnames == ['x_centroid'] def test_to_table_str_column(self, make_catalog): """ Regression test to ensure that a single string column name (not wrapped in a list) is accepted by ``to_table``. """ cat = make_catalog() tbl = cat.to_table(columns='id') assert tbl.colnames == ['id'] def test_properties(self, make_catalog): """ Test that the _properties attribute returns a sorted list of cached-property names. """ cat = make_catalog() assert cat._properties == sorted(cat._properties) assert 'x_centroid' in cat._properties assert 'y_centroid' in cat._properties assert 'data' not in cat._properties assert 'isscalar' not in cat._properties def test_getitem_integer_index(self, make_catalog): """ Test indexing the catalog with an integer index. """ cat = make_catalog(n_sources=3) assert len(cat) == 3 sub = cat[0] assert len(sub) == 1 assert sub.xypos[0, 0] == 5 def test_getitem_slice(self, make_catalog): """ Test slicing the catalog. """ cat = make_catalog(n_sources=3) assert len(cat) == 3 sub = cat[1:] assert len(sub) == 2 assert sub.xypos[0, 0] == 10 assert sub.xypos[1, 0] == 15 def test_getitem_fancy_index(self, make_catalog): """ Test indexing with a boolean mask (fancy indexing). """ cat = make_catalog(n_sources=3) # Force evaluation of a cached property before slicing _ = cat.flux mask = np.array([True, False, True]) sub = cat[mask] assert len(sub) == 2 assert sub.xypos[0, 0] == 5 assert sub.xypos[1, 0] == 15 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, make_catalog): """ Test the __repr__ of StarFinderCatalogBase subclass. """ cat = make_catalog(n_sources=3) r = repr(cat) assert '_MinimalCatalog(' in r assert 'n_sources=3' in r def test_str(self, make_catalog): """ Test the __str__ of StarFinderCatalogBase subclass. """ cat = make_catalog() s = str(cat) assert '_MinimalCatalog' in s assert 'n_sources: 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] assert_array_equal(cutouts[0], expected) def test_select_brightest(self, make_catalog): """ Test select_brightest selects the top sources by flux. """ cat = make_catalog(n_sources=3, 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, make_catalog): """ Test that select_brightest with n_brightest=None keeps all sources. """ cat = make_catalog(n_sources=3, n_brightest=None) newcat = cat.select_brightest() assert len(newcat) == 3 def test_reset_ids(self, make_catalog): """ Test that reset_ids renumbers the catalog consecutively. """ cat = make_catalog(n_sources=3) # Slice to drop the first source sub = cat[1:] assert sub.id[0] == 2 sub.reset_ids() assert_array_equal(sub.id, [1, 2]) def test_apply_all_filters(self, make_catalog): """ Test apply_all_filters chains apply_filters, select_brightest, and reset_ids. """ cat = make_catalog(n_sources=3, n_brightest=2) result = cat.apply_all_filters() assert result is not None assert len(result) == 2 # IDs should be reset to [1, 2] assert_array_equal(result.id, [1, 2]) def test_getitem_negative_index(self, make_catalog): """ Test indexing with a negative integer index. """ cat = make_catalog(n_sources=3) sub = cat[-1] assert len(sub) == 1 assert sub.xypos[0, 0] == 15 def test_getitem_empty_boolean_mask(self, make_catalog): """ Test indexing with an all-False boolean mask. """ cat = make_catalog(n_sources=3) mask = np.array([False, False, False]) sub = cat[mask] assert len(sub) == 0 def test_getitem_integer_array(self, make_catalog): """ Test indexing with an integer array (fancy indexing). """ cat = make_catalog(n_sources=3) idx = np.array([2, 0]) sub = cat[idx] assert len(sub) == 2 assert sub.xypos[0, 0] == 15 assert sub.xypos[1, 0] == 5 @pytest.mark.parametrize('index', [0, 1, -1, np.int64(2)]) def test_getitem_int_index_cached_multidim(self, make_catalog, index): """ Regression test for integer indexing with cached multidimensional per-source arrays. Previously, an integer index dropped the leading source axis of cached >=2D per-source arrays (e.g., moments and cutouts), and accessing dependent properties on the indexed catalog raised an IndexError. """ cat = make_catalog(n_sources=3) # Cache multidimensional per-source arrays before indexing _ = cat.moments _ = cat.cutout_data _ = cat.cutout_centroid sub = cat[index] i = int(index) % len(cat) assert sub.moments.shape == (1, 2, 2) assert sub.cutout_data.shape == (1, 3, 3) assert sub.cutout_centroid.shape == (1, 2) assert_array_equal(sub.moments[0], cat.moments[i]) assert_array_equal(sub.cutout_data[0], cat.cutout_data[i]) # Dependent properties must be computable from the sliced # cached values assert sub.cutout_x_centroid[0] == cat.cutout_x_centroid[i] assert sub.peak[0] == cat.peak[i] def test_filter_finite_skip_attrs(self, make_catalog): """ Test that _filter_finite skips attributes listed in skip_attrs. """ cat = make_catalog(n_sources=3) # Inject a cached flux with a nonfinite value cat.__dict__['flux'] = np.array([1.0, np.nan, 2.0]) result = cat._filter_finite(('flux',), skip_attrs=('flux',)) assert len(result) == 3 result = cat._filter_finite(('flux',)) assert len(result) == 2 def test_filter_bounds_none_range(self, make_catalog): """ Test that _filter_bounds skips filtering when a range is None. """ cat = make_catalog(n_sources=3) bounds = [('flux', None)] result = cat._filter_bounds(bounds) assert len(result) == 3 def test_filter_bounds_initial_mask(self, make_catalog): """ Test _filter_bounds with an initial_mask. """ cat = make_catalog(n_sources=3) # Exclude the first source with the initial mask 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, make_catalog): """ Test that default_columns is preserved when slicing. """ cat = make_catalog(n_sources=3) cat.default_columns = ('id', 'x_centroid', 'y_centroid') sub = cat[0] assert sub.default_columns == ('id', 'x_centroid', 'y_centroid') class TestThreadSafety: """ Thread-safety tests for the star finder and catalog classes. """ def test_concurrent_find_stars_shared_finder(self, data): """ Test that concurrent find_stars calls on a shared finder instance give results identical to a serial call. Finder instances are immutable after construction and create a fresh catalog per call, so concurrent calls must not interfere. """ finder = DAOStarFinder(threshold=5.0, fwhm=2.0) expected = finder(data) n_threads = 8 barrier = threading.Barrier(n_threads) def worker(_): barrier.wait() return finder(data) with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, range(n_threads))) for tbl in results: assert len(tbl) == len(expected) for col in expected.colnames: assert_array_equal(tbl[col], expected[col]) def test_concurrent_cached_property_access(self, make_catalog): """ Test concurrent first access of cached properties on a shared catalog. cached_property does not lock, so concurrent first accesses may run a getter more than once, but every thread must see values identical to the serial computation. """ expected = make_catalog(n_sources=3) cat = make_catalog(n_sources=3) n_threads = 8 barrier = threading.Barrier(n_threads) def worker(_): barrier.wait() return (cat.flux, cat.moments, cat.cutout_centroid) with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, range(n_threads))) for flux, moments, cutout_centroid in results: assert_array_equal(flux, expected.flux) assert_array_equal(moments, expected.moments) assert_array_equal(cutout_centroid, expected.cutout_centroid) def test_concurrent_cached_properties_introspection(self): """ Test concurrent first access of the class-level _cached_properties introspection cache on a fresh class. The lazy class-level cache is written without a lock. The race is benign because the computed list is identical for every thread. """ cls = _make_minimal_catalog_class() data = np.zeros((11, 11)) data[5, 5] = 10.0 kernel = np.ones((3, 3)) xypos = np.array([[5, 5]]) cats = [cls(data, xypos, kernel) for _ in range(8)] n_threads = len(cats) barrier = threading.Barrier(n_threads) def worker(cat): barrier.wait() return cat._cached_properties with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, cats)) assert all(result == results[0] for result in results) assert 'flux' in results[0] 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: 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(PhotutilsDeprecationWarning, 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-24be529/photutils/detection/tests/test_daofinder.py000066400000000000000000000633361526452531300263420ustar00rootroot00000000000000# 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 numpy.testing import assert_allclose, assert_array_equal from scipy.ndimage import convolve as ndi_convolve from photutils.detection.daofinder import DAOStarFinder from photutils.utils.exceptions import (NoDetectionsWarning, PhotutilsDeprecationWarning) 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']) > 100 # 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_flux_kernel_footprint(self, data): """ Test that the flux is the sum of the data values within the kernel footprint, not the full kernel array. """ finder = DAOStarFinder(threshold=5.0, fwhm=3.0) mask = finder.kernel.mask.astype(bool) assert not np.all(mask) tbl = finder(data) ysize, xsize = mask.shape xypos = finder._get_raw_catalog(data).apply_all_filters().xypos for row, (xpos, ypos) in zip(tbl, xypos, strict=True): y0 = ypos - ysize // 2 x0 = xpos - xsize // 2 cutout = data[y0:y0 + ysize, x0:x0 + xsize] assert_allclose(row['flux'], np.sum(cutout[mask])) assert row['flux'] < np.sum(cutout) assert_allclose(tbl['mag'], -2.5 * np.log10(tbl['flux'])) 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_xycoords_non_integer(self, data): """ Test that noninteger xycoords are rounded to the nearest pixel. """ finder0 = DAOStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = np.transpose((tbl0['x_centroid'], tbl0['y_centroid'])) xycoords = np.round(xycoords) finder1 = DAOStarFinder(threshold=8.0, fwhm=2, xycoords=xycoords + 0.3) tbl1 = finder1(data) assert_array_equal(tbl0, tbl1) @pytest.mark.parametrize('xycoords', [[[500, 50]], [[50, 500]], [[-50, 50]], [[50, -50]]]) def test_xycoords_out_of_bounds(self, data, xycoords): """ Test that out-of-bounds xycoords raise a ValueError. """ match = 'xycoords must be within the bounds' finder = DAOStarFinder(threshold=5.0, fwhm=2.0, xycoords=np.array(xycoords)) with pytest.raises(ValueError, match=match): finder(data) # 2D threshold uses per-source threshold lookups finder = DAOStarFinder(threshold=np.full(data.shape, 5.0), fwhm=2.0, xycoords=np.array(xycoords)) with pytest.raises(ValueError, match=match): finder(data) 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('theta', [0.0, 90.0]) def test_roundness1_nonsquare_kernel(self, data, theta): """ Test that roundness1 is computed over the central square of a non-square kernel, as in DAOFIND. """ tbl0 = DAOStarFinder(threshold=5.0, fwhm=3.5)(data) xycoords = np.round(np.transpose((tbl0['x_centroid'], tbl0['y_centroid']))) xycoords = xycoords.astype(int) finder = DAOStarFinder(threshold=1, fwhm=8.0, ratio=0.5, theta=theta, xycoords=xycoords, sharpness_range=None, roundness_range=None) ny, nx = finder.kernel.shape assert ny != nx tbl = finder(data) assert len(tbl) == len(xycoords) # Explicit DAOFIND sums over the convolved data conv = ndi_convolve(data, finder.kernel.data, mode='constant') n_half = min(ny, nx) // 2 expected = [] for xpos, ypos in xycoords: sum2 = 0.0 sum4 = 0.0 for k in range(n_half + 1): for j in range(1, n_half + 1): values = (conv[ypos - j, xpos - k], conv[ypos + j, xpos + k], -conv[ypos + k, xpos - j], -conv[ypos - k, xpos + j]) sum2 += np.sum(values) sum4 += np.sum(np.abs(values)) expected.append(2.0 * sum2 / sum4) assert_allclose(tbl['roundness1'], expected, rtol=1e-12) def test_centroid_outside_image(self): """ Test that sources with a centroid outside the image are rejected, as in DAOFIND. """ rng = np.random.default_rng(1) shape = (80, 90) data = rng.normal(0.0, 1.0, shape) finder = DAOStarFinder(threshold=1.0, fwhm=2.0, min_separation=0, sharpness_range=None, roundness_range=None) tbl = finder(data) assert np.all(tbl['x_centroid'] >= -0.5) assert np.all(tbl['x_centroid'] <= shape[1] - 0.5) assert np.all(tbl['y_centroid'] >= -0.5) assert np.all(tbl['y_centroid'] <= shape[0] - 0.5) @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_getitem_int_index_cached_multidim(self, data): """ Regression test for integer indexing of the catalog after multidimensional per-source arrays (e.g., the cutouts and marginal-fit results) have been computed. Previously, the integer index dropped the leading source axis of those cached arrays, and accessing dependent properties (e.g., ``peak``) on the indexed catalog raised an IndexError. """ finder = DAOStarFinder(threshold=5.0, fwhm=2.0) cat = finder._get_raw_catalog(data) _ = cat.cutout_data _ = cat.dx_hx sub = cat[0] assert sub.cutout_data.shape == (1, *cat.cutout_shape) assert sub.dx_hx.shape == (1, 2) assert sub.peak[0] == cat.peak[0] assert sub.x_centroid[0] == cat.x_centroid[0] def test_interval_ends_included(self): """ Test that filter interval endpoints are inclusive. See issue: 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(PhotutilsDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, sharplo=0.1) assert finder.sharpness_range == (0.1, 1.0) with pytest.warns(PhotutilsDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, sharphi=2.0) assert finder.sharpness_range == (0.2, 2.0) with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, roundlo=-0.5) assert finder.roundness_range == (-0.5, 1.0) with pytest.warns(PhotutilsDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, roundhi=0.5) assert finder.roundness_range == (-1.0, 0.5) with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): finder = DAOStarFinder(threshold=5.0, fwhm=3.0, peakmax=100.0) assert finder.peak_max == 100.0 astropy-photutils-24be529/photutils/detection/tests/test_irafstarfinder.py000066400000000000000000000567101526452531300274100ustar00rootroot00000000000000# 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 numpy.testing import assert_allclose, assert_array_equal from photutils.detection import IRAFStarFinder from photutils.psf import CircularGaussianPRF from photutils.utils.exceptions import (NoDetectionsWarning, PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning), 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_xycoords_non_integer(self, data): """ Test that noninteger xycoords are rounded to the nearest pixel. """ finder0 = IRAFStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = np.transpose((tbl0['x_centroid'], tbl0['y_centroid'])) xycoords = np.round(xycoords) finder1 = IRAFStarFinder(threshold=8.0, fwhm=2, xycoords=xycoords + 0.3) tbl1 = finder1(data) assert_array_equal(tbl0, tbl1) @pytest.mark.parametrize('xycoords', [[[500, 50]], [[50, 500]], [[-50, 50]], [[50, -50]]]) def test_xycoords_out_of_bounds(self, data, xycoords): """ Test that out-of-bounds xycoords raise a ValueError. """ match = 'xycoords must be within the bounds' finder = IRAFStarFinder(threshold=5.0, fwhm=2.0, xycoords=np.array(xycoords)) with pytest.raises(ValueError, match=match): finder(data) 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. See issue: 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_min_n_pixels(self): """ Test that sources with fewer than min_n_pixels positive pixels are rejected. """ rng = np.random.default_rng(1) data = rng.normal(0.0, 1.0, (80, 90)) finder = IRAFStarFinder(threshold=0.5, fwhm=2.0, min_separation=2, sharpness_range=None, roundness_range=None) tbl = finder(data) assert len(tbl) > 100 assert np.min(tbl['n_pixels']) == 5 finder2 = IRAFStarFinder(threshold=0.5, fwhm=2.0, min_separation=2, sharpness_range=None, roundness_range=None, min_n_pixels=2) assert 'min_n_pixels=2' in repr(finder2) tbl2 = finder2(data) assert len(tbl2) > len(tbl) assert np.min(tbl2['n_pixels']) == 2 match = 'min_n_pixels must be >= 1' with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=0.5, fwhm=2.0, min_n_pixels=0) def test_centroid_outside_image(self): """ Test that sources with a centroid outside the image are rejected, as in IRAF's starfind. """ rng = np.random.default_rng(1) shape = (80, 90) data = rng.normal(0.0, 1.0, shape) finder = IRAFStarFinder(threshold=0.5, fwhm=2.0, min_separation=2, sharpness_range=None, roundness_range=None) tbl = finder(data) assert len(tbl) > 100 assert np.all(tbl['x_centroid'] >= -0.5) assert np.all(tbl['x_centroid'] <= shape[1] - 0.5) assert np.all(tbl['y_centroid'] >= -0.5) assert np.all(tbl['y_centroid'] <= shape[0] - 0.5) 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 n_sources = len(cat) # sky should be finite and have same length as nsources sky = cat.sky assert sky.shape == (n_sources,) assert np.all(np.isfinite(sky)) # cutout_data_nosub should have shape (n_sources, ky, kx) with no # sky subtraction cdata = cat.cutout_data_nosub assert cdata.ndim == 3 assert cdata.shape[0] == n_sources # cutout_xorigin/cutout_yorigin should be finite 1D arrays xorig = cat.cutout_xorigin yorig = cat.cutout_yorigin assert xorig.shape == (n_sources,) assert yorig.shape == (n_sources,) 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 == (n_sources,) assert np.all(np.isfinite(sharpness)) def test_sky_no_sky_pixels(self, data): """ Test the sky estimate when the kernel footprint covers the entire kernel array. The sky is then the central data value minus the central convolved data value, as in IRAF starfind. """ finder = IRAFStarFinder(threshold=1.0, fwhm=4.6) assert np.all(finder.kernel.mask == 1) cat = finder._get_raw_catalog(data) xpos, ypos = cat.xypos.T expected = data[ypos, xpos] - cat.convolved_data[ypos, xpos] assert_allclose(cat.sky, expected) tbl = finder(data) assert len(tbl) > 0 assert np.all(np.isfinite(tbl['flux'])) tbl_units = IRAFStarFinder(threshold=1.0 * u.Jy, fwhm=4.6)( data << u.Jy) assert tbl_units['flux'].unit == u.Jy assert_allclose(tbl_units['flux'].value, tbl['flux']) 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(PhotutilsDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, sharplo=0.3) assert finder.sharpness_range == (0.3, 2.0) with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): finder = IRAFStarFinder(threshold=5.0, fwhm=3.0, roundlo=-0.1) assert finder.roundness_range == (-0.1, 0.2) with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/detection/tests/test_peakfinder.py000066400000000000000000001104641526452531300265120ustar00rootroot00000000000000# 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 scipy.spatial import cKDTree 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_masked_pixels_do_not_suppress_peaks(self): """ Test that a masked pixel brighter than a nearby true peak does not suppress that peak. """ data = np.zeros((20, 20)) data[10, 10] = 100.0 # bad pixel (masked) data[10, 12] = 50.0 # real peak, 2 pixels away mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True tbl = find_peaks(data, 1.0, box_size=5, mask=mask) assert len(tbl) == 1 assert tbl['x_peak'][0] == 12 assert tbl['y_peak'][0] == 10 def test_masked_pixels_min_separation(self): """ Test that masked pixels do not suppress nearby peaks with the fast circular (min_separation) peak detection. """ data = np.zeros((40, 40)) data[20, 20] = 100.0 # bad pixel (masked) data[20, 24] = 50.0 # real peak, 4 pixels away mask = np.zeros(data.shape, dtype=bool) mask[20, 20] = True tbl = find_peaks(data, 1.0, min_separation=6, mask=mask) assert len(tbl) == 1 assert tbl['x_peak'][0] == 24 assert tbl['y_peak'][0] == 20 def test_mask_equivalent_to_nan(self): """ Test that masking a pixel gives the same result as setting it to NaN. """ rng = np.random.default_rng(0) data = rng.normal(0.0, 1.0, (50, 50)) data[25, 25] = 100.0 data[25, 27] = 50.0 mask = np.zeros(data.shape, dtype=bool) mask[25, 25] = True data_nan = data.copy() data_nan[25, 25] = np.nan tbl_mask = find_peaks(data, 10.0, box_size=5, mask=mask) tbl_nan = find_peaks(data_nan, 10.0, box_size=5) assert_array_equal(tbl_mask, tbl_nan) 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_n_peaks_integral_float(self, data): """ Test that an integral float n_peaks works like the integer. """ tbl1 = find_peaks(data, 0.1, box_size=3, n_peaks=5) tbl2 = find_peaks(data, 0.1, box_size=3, n_peaks=5.0) assert_array_equal(tbl1, tbl2) @pytest.mark.parametrize('n_peaks', [0, -1, 5.5]) def test_n_peaks_invalid(self, data, n_peaks): """ Test that nonpositive or nonintegral n_peaks values raise a ValueError. """ match = 'n_peaks must be' with pytest.raises(ValueError, match=match): find_peaks(data, 0.1, box_size=3, n_peaks=n_peaks) def test_n_peaks_bool(self, data): """ Test that a boolean n_peaks raises a TypeError. """ match = 'n_peaks must be an integer' with pytest.raises(TypeError, match=match): find_peaks(data, 0.1, box_size=3, n_peaks=True) def test_empty_data(self): """ Test that empty input data raises a ValueError. """ match = 'data must not be empty' with pytest.raises(ValueError, match=match): find_peaks(np.empty((0, 0)), 0.0) 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. The error array is ignored because centroid_com does not accept an error keyword. """ 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_error_keyword(self, data): """ Test that find_peaks passes the error array to a centroid function that accepts an error keyword. """ received_errors = [] def my_centroid(data, mask=None, error=None): received_errors.append(error) return centroid_com(data, mask=mask) error = np.ones_like(data) * 0.1 tbl = find_peaks(data, 0.1, box_size=3, centroid_func=my_centroid, error=error) assert 'x_centroid' in tbl.colnames assert len(received_errors) == len(tbl) assert all(err is not None for err in received_errors) 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, so 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 @pytest.mark.parametrize('min_separation', [1, 1.5, 5, 15]) def test_min_separation_plateau(self, min_separation): """ Test that a plateau of connected equal-valued pixels gives one peak at its center, even if the plateau is larger than min_separation. """ data = np.zeros((50, 50)) data[20:31, 18:29] = 10.0 # 11x11 plateau tbl = find_peaks(data, 1.0, min_separation=min_separation) assert len(tbl) == 1 assert tbl['x_peak'][0] == 23 assert tbl['y_peak'][0] == 25 def test_min_separation_plateau_tie(self): """ Test that the first pixel in row-major order is kept when plateau pixels are equally close to the plateau centroid. """ data = np.zeros((20, 20)) data[10:12, 5:7] = 10.0 # 2x2 plateau tbl = find_peaks(data, 1.0, min_separation=3) assert len(tbl) == 1 assert tbl['x_peak'][0] == 5 assert tbl['y_peak'][0] == 10 def test_min_separation_plateau_concave(self): """ Test that the peak of a plateau is one of its pixels when the plateau centroid is not in the plateau. """ data = np.zeros((30, 30)) data[10, 10:21] = 10.0 data[10:21, 10] = 10.0 # L-shaped plateau tbl = find_peaks(data, 1.0, min_separation=2) assert len(tbl) == 1 assert data[tbl['y_peak'][0], tbl['x_peak'][0]] == 10.0 def test_min_separation_plateau_exact_tie(self): """ Test that the first pixel in row-major order is kept when plateau pixels are equally close to a plateau centroid that is not exactly represented as a float. """ plateau = np.array([[1, 1, 0], [0, 1, 0], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) data = np.zeros((20, 20)) data[5:10, 8:11] = 10.0 * plateau # the (2, 0) and (3, 1) plateau pixels are equally close to the # centroid at (y, x) = (7/3, 2/3) tbl = find_peaks(data, 1.0, min_separation=3) assert len(tbl) == 1 assert tbl['x_peak'][0] == 8 assert tbl['y_peak'][0] == 7 def test_min_separation_plateau_mask(self): """ Test that a mask splits a plateau into separate plateaus. """ data = np.zeros((30, 30)) data[10:15, 10:21] = 9.0 mask = np.zeros(data.shape, dtype=bool) mask[10:15, 15] = True tbl = find_peaks(data, 1.0, min_separation=3, mask=mask) assert_array_equal(tbl['x_peak'], [12, 18]) assert_array_equal(tbl['y_peak'], [12, 12]) # the two plateaus have the same size tbl = find_peaks(data, 1.0, min_separation=30, mask=mask) assert_array_equal(tbl['x_peak'], [12]) assert_array_equal(tbl['y_peak'], [12]) def test_min_separation_plateau_threshold(self): """ Test that only the plateau pixels above a threshold array are used to find the plateau peak. """ data = np.zeros((30, 30)) data[10:15, 10:21] = 9.0 threshold = np.ones(data.shape) threshold[:, :14] = 100.0 tbl = find_peaks(data, threshold, min_separation=3) assert_array_equal(tbl['x_peak'], [17]) assert_array_equal(tbl['y_peak'], [12]) def test_min_separation_plateau_border(self): """ Test that only the plateau pixels inside the border are used to find the plateau peak. """ data = np.zeros((30, 30)) data[10:15, 10:21] = 9.0 tbl = find_peaks(data, 1.0, min_separation=3, border_width=12) assert_array_equal(tbl['x_peak'], [14]) assert_array_equal(tbl['y_peak'], [13]) def test_min_separation_saturated_star(self): """ Test that a saturated star gives one peak near its center. """ yy, xx = np.mgrid[:101, :101] data = 5.0e5 * np.exp(-((xx - 50.3)**2 + (yy - 49.6)**2) / 18.0) data = np.minimum(data, 65535.0) assert np.count_nonzero(data == 65535.0) > 100 tbl = find_peaks(data, 500.0, min_separation=4) assert len(tbl) == 1 assert tbl['x_peak'][0] == 50 assert tbl['y_peak'][0] == 50 def test_min_separation_plateaus_separate(self): """ Test that separate plateaus each give one peak. """ data = np.zeros((60, 60)) data[10:15, 10:15] = 10.0 data[40:45, 30:35] = 10.0 data[20:23, 40:43] = 8.0 tbl = find_peaks(data, 1.0, min_separation=3) assert len(tbl) == 3 assert_array_equal(tbl['x_peak'], [12, 41, 32]) assert_array_equal(tbl['y_peak'], [12, 21, 42]) def test_min_separation_plateau_preferred(self): """ Test that the peak of a plateau is kept instead of a nearby equal-valued isolated pixel, regardless of their order in the image. """ for y_pixel in (8, 32): data = np.zeros((41, 41)) data[17:24, 17:24] = 10.0 # 7x7 plateau centered at (20, 20) data[y_pixel, 20] = 10.0 # not connected to the plateau tbl = find_peaks(data, 1.0, min_separation=15) assert len(tbl) == 1 assert tbl['x_peak'][0] == 20 assert tbl['y_peak'][0] == 20 tbl = find_peaks(data, 1.0, min_separation=5) assert len(tbl) == 2 def test_min_separation_subpixel(self): """ Test that adjacent pixels are separate peaks for a min_separation smaller than 1 pixel. """ data = np.zeros((20, 20)) data[10, 5:8] = [10.0, 10.0, 7.0] tbl = find_peaks(data, 1.0, min_separation=0.5) assert len(tbl) == 3 def test_min_separation_diagonal_plateau(self): """ Test that equal-valued diagonal neighbors are one plateau only if they are within min_separation of each other. """ data = np.zeros((20, 20)) data[10, 10] = 10.0 data[11, 11] = 10.0 tbl = find_peaks(data, 1.0, min_separation=1.2) assert len(tbl) == 2 tbl = find_peaks(data, 1.0, min_separation=1.5) assert len(tbl) == 1 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, so n_peaks=2 should keep the # 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 @pytest.mark.parametrize('offset', [0.0, -30.0]) @pytest.mark.parametrize('radius', [0.5, 1, 1.5, 2.05, 2.5, 4, 4.55, 5, 7.3, 10, 12.5, 25, 50]) def test_min_separation_matches_circular_footprint(self, radius, offset): """ Test that min_separation produces a subset of the peaks found by an equivalent circular footprint. The min_separation result is at least as strict, because equal-valued peaks are further pruned. The radii cover both the footprint filter used for small radii and the box filters used for large radii. The fractional radii are typical of the default star finder path (min_separation = 2.5 * fwhm). The negative offset tests the zero padding at the image borders. """ rng = np.random.default_rng(42) data = rng.standard_normal((200, 200)) + offset data[50, 50] = 20.0 data[120, 130] = 18.0 data[30, 170] = 15.0 threshold = 3.0 + offset # Reference uses the actual circular footprint (slow but correct) half = int(radius) idx = np.arange(-half, half + 1) xx, yy = np.meshgrid(idx, idx) fp = (xx**2 + yy**2) <= radius**2 tbl_ref = find_peaks(data, threshold, footprint=fp) tbl_fast = find_peaks(data, threshold, min_separation=radius) # The min_separation result is a subset of the footprint # result, because equal-valued peaks may be removed. 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 fast_xy <= ref_xy # All surviving peaks must satisfy min_separation if len(tbl_fast) > 1: x = np.array(tbl_fast['x_peak'], dtype=float) y = np.array(tbl_fast['y_peak'], dtype=float) for i in range(len(tbl_fast)): for j in range(i + 1, len(tbl_fast)): dist = np.sqrt((x[i] - x[j])**2 + (y[i] - y[j])**2) assert dist > radius def test_min_separation_diagonal_neighbors(self): """ Test that diagonal neighbors, which are separated by more than 1 pixel, are both peaks for ``min_separation=1``. """ data = np.zeros((9, 9)) data[3, 3] = 5.0 data[4, 4] = 6.0 tbl = find_peaks(data, 1.0, min_separation=1) assert len(tbl) == 2 tbl = find_peaks(data, 1.0, min_separation=1.5) assert len(tbl) == 1 assert tbl['peak_value'][0] == 6.0 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 nonpeak 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_equal_peaks_pruned(self): """ Test that two equal-valued peaks within min_separation are pruned by the greedy spacing step, keeping only the first one in row-major order. """ radius = 12 data = np.zeros((100, 100)) # Two equal-valued peaks separated by 11 px < radius = 12 px data[50, 40] = 10.0 data[50, 51] = 10.0 tbl = find_peaks(data, 1.0, min_separation=radius) assert len(tbl) == 1 assert tbl['x_peak'][0] == 40 assert tbl['y_peak'][0] == 50 @pytest.mark.parametrize(('radius', 'n_peaks'), [(4.9, 2), (5, 1)]) def test_min_separation_equal_peaks_boundary(self, radius, n_peaks): """ Test that equal-valued peaks separated by exactly min_separation are too close, as for peaks with different values. """ data = np.zeros((40, 40)) data[20, 10] = 10.0 data[23, 14] = 10.0 # 5 pixels from the first peak tbl = find_peaks(data, 1.0, min_separation=radius) assert len(tbl) == n_peaks @pytest.mark.parametrize('dtype', [np.uint16, np.int32, np.float32]) def test_min_separation_equal_peaks_dtype(self, dtype): """ Test that equal-valued peaks are reduced for integer and unsigned integer data. """ data = np.zeros((40, 40), dtype=dtype) data[10, [10, 13]] = 500 data[20:23, 30] = 100 data[30, [10, 14]] = 7 tbl = find_peaks(data, 1, min_separation=5) assert_array_equal(tbl['x_peak'], [10, 30, 10]) assert_array_equal(tbl['y_peak'], [10, 21, 30]) assert_array_equal(tbl['peak_value'], [500, 100, 7]) def test_min_separation_equal_peaks_chain(self): """ Test that the greedy spacing step keeps a peak that is too close only to a peak that was itself removed. """ data = np.zeros((20, 40)) data[10, [10, 14, 18]] = 10.0 tbl = find_peaks(data, 1.0, min_separation=5) assert_array_equal(tbl['x_peak'], [10, 18]) @pytest.mark.parametrize('radius', [3, 8, 20]) def test_min_separation_many_pairs(self, radius, monkeypatch): """ Test that the two ways of finding the equal-valued peaks that are too close give the same peaks. A sparse image of ones has many pairs of equal-valued peaks. """ from photutils.detection import peakfinder rng = np.random.default_rng(0) data = (rng.random((150, 150)) < 0.1).astype(float) monkeypatch.setattr(peakfinder, '_MAX_PAIRS_PER_PEAK', np.inf) tbl1 = find_peaks(data, 0.5, min_separation=radius) monkeypatch.setattr(peakfinder, '_MAX_PAIRS_PER_PEAK', 0) tbl2 = find_peaks(data, 0.5, min_separation=radius) assert len(tbl1) > 10 assert_array_equal(tbl1['x_peak'], tbl2['x_peak']) assert_array_equal(tbl1['y_peak'], tbl2['y_peak']) xypos = np.transpose((tbl1['x_peak'], tbl1['y_peak'])) assert len(cKDTree(xypos).query_pairs(radius)) == 0 def test_min_separation_integer_data(self): """ Test that all peaks are separated by more than min_separation for integer-valued data, which has many equal-valued peaks. """ rng = np.random.default_rng(0) data = rng.poisson(5, (200, 200)).astype(float) radius = 5 tbl = find_peaks(data, 8, min_separation=radius) xypos = np.transpose((tbl['x_peak'], tbl['y_peak'])) assert len(cKDTree(xypos).query_pairs(radius)) == 0 # the peaks stay in row-major order idx = np.lexsort((tbl['x_peak'], tbl['y_peak'])) assert_array_equal(idx, np.arange(len(tbl))) 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 @pytest.mark.parametrize('axis', ['x', 'y']) def test_min_separation_noninteger_radius_regression(self, axis): """ Regression test for noninteger min_separation with peaks separated by 12 pixels (< min_separation = 12.5). With a noninteger radius the circular footprint had an even number of elements, causing an asymmetric half-pixel shift that allowed the fainter peak to survive depending on which side of the brighter peak it was placed. """ data = np.zeros((100, 100)) if axis == 'x': data[50, 40] = 15.0 # brighter data[50, 52] = 10.0 # fainter, 12 px away else: data[40, 50] = 15.0 # brighter data[52, 50] = 10.0 # fainter, 12 px away # 12 < 12.5, so only the brighter peak should survive tbl = find_peaks(data, 1.0, min_separation=12.5) assert len(tbl) == 1 assert tbl['peak_value'][0] == 15.0 def test_min_separation_equal_at_boundary(self): """ Test that two peaks separated by more than min_separation are both retained. """ data = np.zeros((100, 100)) data[50, 30] = 10.0 data[50, 44] = 8.0 # distance = 14 > 13 tbl = find_peaks(data, 1.0, min_separation=13) assert len(tbl) == 2 astropy-photutils-24be529/photutils/detection/tests/test_starfinder.py000066400000000000000000000330201526452531300265330ustar00rootroot00000000000000# 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 numpy.testing import assert_allclose, assert_array_equal, assert_equal from photutils.detection import StarFinder from photutils.utils.exceptions import (NoDetectionsWarning, PhotutilsDeprecationWarning) 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_kernel_nonpositive(self): """ Test that kernels without a positive maximum raise ValueError. """ match = 'kernel must have a positive maximum value' for bad_kernel in (np.zeros((3, 3)), -np.ones((3, 3))): with pytest.raises(ValueError, match=match): StarFinder(1, bad_kernel) def test_kernel_nonfinite(self): """ Test that kernels with nonfinite values raise ValueError. """ match = 'kernel must contain only finite values' for bad_value in (np.nan, np.inf): kernel = np.ones((3, 3)) kernel[1, 1] = bad_value with pytest.raises(ValueError, match=match): StarFinder(1, 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): """ 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)) # min_separation=0 keeps the equal-valued peaks finder0 = StarFinder(1, kernel, exclude_border=False, min_separation=0) finder1 = StarFinder(1, kernel, exclude_border=True, min_separation=0) 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 # The 14 sources include one whose unmasked peak pixel lies at # the mask boundary (its centroid falls just below it). Masked # pixels do not suppress nearby peaks. assert len(tbl2) == 14 assert min(tbl2['y_centroid']) > 49.5 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_equal_values(self): """ Test that equal-valued pixels of the convolved image give one source, unless min_separation is 0. """ data = np.zeros((40, 40)) data[18:23, 18:23] = 100.0 kernel = np.ones((3, 3)) tbl = StarFinder(1, kernel)(data) assert len(tbl) == 1 assert_allclose(tbl['x_centroid'], 20.0) assert_allclose(tbl['y_centroid'], 20.0) tbl = StarFinder(1, kernel, min_separation=0)(data) assert len(tbl) == 9 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) # Nonsquare 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): finder = StarFinder(threshold=5.0, kernel=kernel, peakmax=100.0) assert finder.peak_max == 100.0 astropy-photutils-24be529/photutils/extension.py000066400000000000000000000066011526452531300222340ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ ASDF extension for photutils. """ import importlib.resources as importlib_resources from asdf.extension import ManifestExtension from asdf.resource import DirectoryResourceMapping from .converters import _ASDF_ASTROPY_INSTALLED, apertures __all__ = [ 'PHOTUTILS_APERTURE_CONVERTERS', 'PHOTUTILS_CONVERTERS', 'PHOTUTILS_MANIFEST_URIS', 'PHOTUTILS_PSF_CONVERTERS', 'get_extensions', 'get_resource_mappings', ] PHOTUTILS_APERTURE_CONVERTERS = [ apertures.CircularApertureConverter(), apertures.CircularAnnulusConverter(), apertures.EllipticalApertureConverter(), apertures.EllipticalAnnulusConverter(), apertures.PolygonApertureConverter(), apertures.RectangularApertureConverter(), apertures.RectangularAnnulusConverter(), ] PHOTUTILS_PSF_CONVERTERS = [] # The PSF converters are built on the asdf-astropy transform machinery. # asdf rejects an entire extension that contains a converter which does # not implement its interface, so registering the PSF converters when # asdf-astropy is missing would also disable the aperture converters, # which need only asdf. if _ASDF_ASTROPY_INSTALLED: from .converters import functional_models, image_models PHOTUTILS_PSF_CONVERTERS += [ functional_models.AiryDiskPRFConverter(), functional_models.AiryDiskPSFConverter(), functional_models.CircularGaussianPRFConverter(), functional_models.CircularGaussianPSFConverter(), functional_models.CircularGaussianSigmaPRFConverter(), functional_models.GaussianPRFConverter(), functional_models.GaussianPSFConverter(), functional_models.MoffatPRFConverter(), functional_models.MoffatPSFConverter(), image_models.ImagePSFConverter(), image_models.GriddedPSFModelConverter(), image_models.STDPSFGridConverter(), ] PHOTUTILS_CONVERTERS = PHOTUTILS_PSF_CONVERTERS + PHOTUTILS_APERTURE_CONVERTERS # The order here is important. asdf will prefer to use extensions # that occur earlier in the list. PHOTUTILS_MANIFEST_URIS = [ 'asdf://astropy.org/photutils/manifests/photutils-1.0.0', ] def get_extensions(): """ Get the photutils ASDF extensions. This function is registered with the ``asdf.extensions`` entry point. Returns ------- extensions : list of ``asdf.extension.ManifestExtension`` The ASDF extensions, one for each photutils manifest. """ return [ ManifestExtension.from_uri( uri, converters=PHOTUTILS_CONVERTERS, ) for uri in PHOTUTILS_MANIFEST_URIS ] def get_resource_mappings(): """ Get the resource mappings for the photutils schemas and manifests. This function is registered with the ``asdf.resource_mappings`` entry point. Returns ------- mappings : list of `collections.abc.Mapping` The ASDF resource mappings for the schemas and the manifests. """ from . import resources resources_root = importlib_resources.files(resources) return [ DirectoryResourceMapping(resources_root / 'schemas', 'asdf://astropy.org/photutils/schemas', recursive=True), DirectoryResourceMapping(resources_root / 'manifests', 'asdf://astropy.org/photutils/manifests'), ] astropy-photutils-24be529/photutils/geometry/000077500000000000000000000000001526452531300214765ustar00rootroot00000000000000astropy-photutils-24be529/photutils/geometry/__init__.py000066400000000000000000000007561526452531300236170ustar00rootroot00000000000000# 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 .circle_overlap import * # noqa: F401, F403 from .ellipse_overlap import * # noqa: F401, F403 from .rectangle_overlap import * # noqa: F401, F403 astropy-photutils-24be529/photutils/geometry/_polygon_overlap.pxd000066400000000000000000000133701526452531300255750ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, cdivision=True """ Declarations needed to cimport the polygon overlap functions into other Cython files. These single-pixel functions are pure C math functions that are safe to call without the GIL. """ cdef double polygon_pixel_overlap(double pxmin, double pymin, double pxmax, double pymax, double *poly_x, double *poly_y, int n_poly, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int buf_size) noexcept nogil cdef int convex_edge_normals(double *poly_x, double *poly_y, int n_poly, double *edge_nx, double *edge_ny, double *edge_c) noexcept nogil cdef double convex_polygon_pixel_overlap(double pxmin, double pymin, double pxmax, double pymax, double *poly_x, double *poly_y, int n_poly, double *edge_nx, double *edge_ny, double *edge_c, int is_convex, double margin, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int buf_size) noexcept nogil cdef double convex_polygon_pixel_subpixel(double pxmin, double pymin, double pxmax, double pymax, double *poly_x, double *poly_y, int n_poly, double *edge_nx, double *edge_ny, double *edge_c, int is_convex, double margin, int subpixels, double *xint_buf, double *hxmin_buf, double *hxmax_buf) noexcept nogil cdef double polygon_overlap_single_subpixel(double x0, double y0, double x1, double y1, double *poly_x, double *poly_y, int n_poly, int subpixels, double *xint_buf, double *hxmin_buf, double *hxmax_buf) noexcept nogil cdef inline Py_ssize_t polygon_work_size(int n_poly, int *buf_size) noexcept nogil: """ Total number of doubles needed for a polygon overlap workspace. The workspace holds the polygon vertices, the four Sutherland- Hodgman clip buffers, and the three edge-normal arrays used by the convex fast path (see ``polygon_work_partition`` for the layout). Each Sutherland-Hodgman clip can at most double the vertex count of a nonconvex polygon, so after the four half-plane clips the vertex count is bounded by ``16 * n_poly``. Parameters ---------- n_poly : int The number of polygon vertices. buf_size : int * Output. The per-buffer size (``16 * n_poly``) used for each of the four Sutherland-Hodgman clip buffers. Returns ------- total : Py_ssize_t The total number of doubles needed for the workspace. """ buf_size[0] = 16 * n_poly return 2 * n_poly + 4 * buf_size[0] + 3 * n_poly cdef inline void polygon_work_partition(double *base, int n_poly, int buf_size, double **poly_x, double **poly_y, double **buf_a_x, double **buf_a_y, double **buf_b_x, double **buf_b_y, double **edge_nx, double **edge_ny, double **edge_c) noexcept nogil: """ Partition a polygon overlap workspace into its component arrays. Centralizing the offset arithmetic here keeps the buffer layout consistent wherever it is used. This runs once per call (not per pixel), so the pointer-output form has no effect on the per-pixel loops. Parameters ---------- base : double * The workspace buffer, of at least the number of doubles returned by ``polygon_work_size``. The layout is the polygon vertex arrays (``n_poly`` each), the four clip buffers (``buf_size`` each), and the three edge-normal arrays (``n_poly`` each). n_poly : int The number of polygon vertices. buf_size : int The size of each of the four clip buffers (see ``polygon_work_size``). poly_x, poly_y : double ** Output. Pointers into ``base`` for the polygon vertex arrays. buf_a_x, buf_a_y, buf_b_x, buf_b_y : double ** Output. Pointers into ``base`` for the four Sutherland-Hodgman clip buffers. edge_nx, edge_ny, edge_c : double ** Output. Pointers into ``base`` for the three convex-fast-path edge-normal arrays. """ poly_x[0] = base poly_y[0] = base + n_poly buf_a_x[0] = base + 2 * n_poly buf_a_y[0] = base + 2 * n_poly + buf_size buf_b_x[0] = base + 2 * n_poly + 2 * buf_size buf_b_y[0] = base + 2 * n_poly + 3 * buf_size edge_nx[0] = base + 2 * n_poly + 4 * buf_size edge_ny[0] = base + 3 * n_poly + 4 * buf_size edge_c[0] = base + 4 * n_poly + 4 * buf_size astropy-photutils-24be529/photutils/geometry/_polygon_overlap.pyx000066400000000000000000001106761526452531300256310ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Tools to calculate the area of overlap between a simple polygon and a pixel grid. The functions here are written so that they can be reused by any aperture whose footprint is a simple polygon (e.g., rotated rectangles, regular polygons, or arbitrary user-supplied polygons). Convexity is not required. The subject is the aperture polygon and the clip polygon is the (always-convex, axis-aligned) pixel rectangle. The cdef functions are not intended to be called from Python code. They are pure C math functions declared ``noexcept nogil`` so they can be called without the GIL, including from multiple threads on free-threaded Python builds. The signatures of the functions used by other modules are exported via _polygon_overlap.pxd for reuse elsewhere. """ import numpy as np cimport numpy as np __all__ = ['polygon_overlap_grid'] cdef extern from "math.h" nogil: double sqrt(double x) double fabs(double x) double fmax(double x, double y) double fmin(double x, double y) double floor(double x) double ceil(double x) DTYPE = np.float64 ctypedef np.float64_t DTYPE_t def polygon_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, vertices_x, vertices_y, int use_exact, int subpixels): """ Calculate the fractional overlap between a simple polygon and a pixel grid. The polygon vertices must define a simple polygon (no self-intersections). The polygon may be convex or nonconvex. The vertex order may be either clockwise or counterclockwise. Clockwise input is reversed internally. Parameters ---------- xmin, xmax, ymin, ymax : float The extent of the grid in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax). nx, ny : int The grid dimensions in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax) and is divided into nx and ny pixels in the x and y direction, respectively. vertices_x, vertices_y : 1D array_like The x and y coordinates of the polygon vertices (in the same coordinate frame as the grid extents). The inputs are converted to contiguous float64 arrays. The polygon must have at least 3 vertices. use_exact : 0 or 1 Set to ``1`` to use an exact method to calculate the overlap between the polygon and each pixel. Set to ``0`` to use a subpixel sampling method to calculate the overlap, where each pixel is divided into ``subpixels ** 2`` subpixels and the fraction of subpixels that are within the polygon is used to estimate the overlap. subpixels : int The number of subpixels to use in each dimension when using the subpixel sampling method. Each pixel is resampled by this factor in each dimension, so each pixel is divided into ``subpixels ** 2`` subpixels. With ``subpixels == 1`` (the ``center`` method) a pixel is included only if its center falls strictly inside the polygon. Pixel centers lying exactly on a polygon edge are counted as outside, consistent with the circular, elliptical, and rectangular apertures (see ``point_in_polygon``). For ``subpixels > 1`` the same convention is applied to each subpixel center. The on-edge test uses exact floating-point comparisons with the computed sample coordinates. A sample that is nominally on an edge can therefore be classified on either side because of rounding, either in the accumulated sample coordinates or in the crossing of a sloped edge. Returns ------- result : `~numpy.ndarray` (float) A 2D array of shape (ny, nx) giving the fraction of each pixel's area that overlaps with the polygon, ranging from 0 to 1. The element at index (j, i) corresponds to the pixel with corners at (xmin + i * dx, ymin + j * dy) and (xmin + (i + 1) * dx, ymin + (j + 1) * dy), where dx and dy are the width of each pixel in the x and y direction, respectively. """ vertices_x = np.ascontiguousarray(vertices_x, dtype=DTYPE) vertices_y = np.ascontiguousarray(vertices_y, dtype=DTYPE) if vertices_x.ndim != 1 or vertices_y.ndim != 1: msg = 'vertices_x and vertices_y must be 1D arrays' raise ValueError(msg) cdef int n_poly = vertices_x.shape[0] if vertices_y.shape[0] != n_poly: msg = 'vertices_x and vertices_y must have the same length' raise ValueError(msg) if n_poly < 3: msg = 'polygon must have at least 3 vertices' raise ValueError(msg) if use_exact != 0 and use_exact != 1: msg = 'use_exact must be 0 or 1' raise ValueError(msg) if use_exact == 0 and subpixels < 1: msg = 'subpixels must be a strictly positive integer' raise ValueError(msg) # Working buffers, allocated once per call (not per pixel) as a # single block whose sizing and layout are centralized in # ``polygon_work_size``/``polygon_work_partition``. cdef int buf_size = 0 cdef double[::1] work = np.empty(polygon_work_size(n_poly, &buf_size), dtype=DTYPE) cdef double *poly_x = NULL cdef double *poly_y = NULL cdef double *buf_a_x = NULL cdef double *buf_a_y = NULL cdef double *buf_b_x = NULL cdef double *buf_b_y = NULL cdef double *edge_nx = NULL cdef double *edge_ny = NULL cdef double *edge_c = NULL polygon_work_partition(&work[0], n_poly, buf_size, &poly_x, &poly_y, &buf_a_x, &buf_a_y, &buf_b_x, &buf_b_y, &edge_nx, &edge_ny, &edge_c) cdef const double[::1] vx_view = vertices_x cdef const double[::1] vy_view = vertices_y _ensure_ccw(vx_view, vy_view, n_poly, poly_x, poly_y) cdef int is_convex = convex_edge_normals(poly_x, poly_y, n_poly, edge_nx, edge_ny, edge_c) cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) cdef double[:, ::1] frac_view = frac cdef double dx = (xmax - xmin) / nx cdef double dy = (ymax - ymin) / ny cdef double pxmin, pxmax, pymin, pymax cdef double bbox_xmin, bbox_xmax, bbox_ymin, bbox_ymax cdef double pixel_area = dx * dy cdef double margin = 0.5 * sqrt(dx * dx + dy * dy) cdef int i, j, k cdef int i_min, i_max, j_min, j_max # Polygon bounding box. bbox_xmin = poly_x[0] bbox_xmax = poly_x[0] bbox_ymin = poly_y[0] bbox_ymax = poly_y[0] for k in range(1, n_poly): bbox_xmin = fmin(bbox_xmin, poly_x[k]) bbox_xmax = fmax(bbox_xmax, poly_x[k]) bbox_ymin = fmin(bbox_ymin, poly_y[k]) bbox_ymax = fmax(bbox_ymax, poly_y[k]) # Restrict the pixel loops to the bounding-box index range (pixels # outside the bounding box have zero overlap). The clamping to # [0, nx] and [0, ny] is done in floating point to avoid integer # overflow for polygons far outside the grid. i_min = fmax(0.0, fmin(nx, floor((bbox_xmin - xmin) / dx))) i_max = fmax(0.0, fmin(nx, ceil((bbox_xmax - xmin) / dx))) j_min = fmax(0.0, fmin(ny, floor((bbox_ymin - ymin) / dy))) j_max = fmax(0.0, fmin(ny, ceil((bbox_ymax - ymin) / dy))) if use_exact == 1: with nogil: for i in range(i_min, i_max): pxmin = xmin + i * dx pxmax = pxmin + dx for j in range(j_min, j_max): pymin = ymin + j * dy pymax = pymin + dy frac_view[j, i] = ( convex_polygon_pixel_overlap( pxmin, pymin, pxmax, pymax, poly_x, poly_y, n_poly, edge_nx, edge_ny, edge_c, is_convex, margin, buf_a_x, buf_a_y, buf_b_x, buf_b_y, buf_size) / pixel_area) else: with nogil: for i in range(i_min, i_max): pxmin = xmin + i * dx for j in range(j_min, j_max): pymin = ymin + j * dy frac_view[j, i] = convex_polygon_pixel_subpixel( pxmin, pymin, pxmin + dx, pymin + dy, poly_x, poly_y, n_poly, edge_nx, edge_ny, edge_c, is_convex, margin, subpixels, buf_a_x, buf_a_y, buf_b_x) return frac cdef double polygon_pixel_overlap(double pxmin, double pymin, double pxmax, double pymax, double *poly_x, double *poly_y, int n_poly, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int buf_size) noexcept nogil: """ Exact area of overlap between the axis-aligned pixel rectangle [pxmin, pxmax] x [pymin, pymax] and a simple polygon supplied as counterclockwise vertices. The polygon (subject) is clipped against the four edges of the pixel rectangle (clip) using the Sutherland-Hodgman algorithm, followed by the shoelace area formula. Because the clip polygon is convex, the subject polygon may be either convex or nonconvex (it must, however, be simple, i.e., non-self-intersecting). Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges. poly_x, poly_y : double * The x and y coordinates of the polygon vertices, in counterclockwise order. n_poly : int The number of polygon vertices. buf_a_x, buf_a_y, buf_b_x, buf_b_y : double * Caller-allocated scratch buffers, each holding at least ``buf_size`` doubles. The caller is responsible for ensuring ``buf_size`` is large enough. Each clip can roughly double the vertex count of a nonconvex polygon, so 16 times the maximum input polygon size is a safe choice, while for convex input polygons each clip adds at most one vertex, so input size plus four suffices. buf_size : int The size of the scratch buffers. Returns ------- area : double The area of overlap between the pixel and the polygon. """ cdef double *cur_x = buf_a_x cdef double *cur_y = buf_a_y cdef double *nxt_x = buf_b_x cdef double *nxt_y = buf_b_y cdef double *tmp cdef int n if n_poly < 3 or n_poly > buf_size: return 0.0 # Clip against x >= pxmin, x <= pxmax, y >= pymin, y <= pymax. # The first clip reads directly from the input polygon, avoiding a # per-pixel copy of the input vertices. n = _clip_against_axis(poly_x, poly_y, n_poly, 0, pxmin, 1, cur_x, cur_y) if n == 0: return 0.0 n = _clip_against_axis(cur_x, cur_y, n, 0, pxmax, 0, nxt_x, nxt_y) if n == 0: return 0.0 tmp = cur_x cur_x = nxt_x nxt_x = tmp tmp = cur_y cur_y = nxt_y nxt_y = tmp n = _clip_against_axis(cur_x, cur_y, n, 1, pymin, 1, nxt_x, nxt_y) if n == 0: return 0.0 tmp = cur_x cur_x = nxt_x nxt_x = tmp tmp = cur_y cur_y = nxt_y nxt_y = tmp n = _clip_against_axis(cur_x, cur_y, n, 1, pymax, 0, nxt_x, nxt_y) if n == 0: return 0.0 return fabs(_polygon_signed_area(nxt_x, nxt_y, n)) cdef int convex_edge_normals(double *poly_x, double *poly_y, int n_poly, double *edge_nx, double *edge_ny, double *edge_c) noexcept nogil: """ Test whether a counterclockwise simple polygon is convex and, if so, fill the per-edge unit inward normals and offsets. For edge ``k`` (from vertex ``k`` to vertex ``k + 1``) of a counterclockwise convex polygon, the inward unit normal is ``(-edge_y, edge_x)`` normalized, and the signed distance of a point ``(x, y)`` from the edge line is ``edge_nx[k] * x + edge_ny[k] * y - edge_c[k]``, positive on the interior side. Parameters ---------- poly_x, poly_y : double * The x and y coordinates of the polygon vertices, in counterclockwise order. n_poly : int The number of polygon vertices. edge_nx, edge_ny, edge_c : double * Output. The per-edge unit inward normals and offsets, each of length ``n_poly``. Valid only if the polygon is convex (the arrays may be partially written otherwise). Returns ------- is_convex : int 1 if the polygon is convex, 0 otherwise. A clockwise polygon, or one with a zero-length edge (repeated consecutive vertices), also returns 0, in which case the caller should fall back to the general Sutherland-Hodgman clip. """ cdef int k, k1, k2 cdef double ex, ey, ex2, ey2, cross, length if n_poly < 3: return 0 for k in range(n_poly): k1 = k + 1 if k1 == n_poly: k1 = 0 k2 = k1 + 1 if k2 == n_poly: k2 = 0 ex = poly_x[k1] - poly_x[k] ey = poly_y[k1] - poly_y[k] ex2 = poly_x[k2] - poly_x[k1] ey2 = poly_y[k2] - poly_y[k1] # Cross product of consecutive edges. For a counterclockwise # convex polygon every turn is a left turn (cross >= 0). A # negative value marks a reflex vertex (nonconvex), and a # uniformly negative sign marks a clockwise polygon. cross = ex * ey2 - ey * ex2 if cross < 0.0: return 0 length = sqrt(ex * ex + ey * ey) if length == 0.0: return 0 edge_nx[k] = -ey / length edge_ny[k] = ex / length edge_c[k] = edge_nx[k] * poly_x[k] + edge_ny[k] * poly_y[k] return 1 cdef int convex_pixel_position(double pxmin, double pymin, double pxmax, double pymax, double *edge_nx, double *edge_ny, double *edge_c, int n_poly, double margin) noexcept nogil: """ Classify a pixel against a convex polygon using the precomputed per-edge normals from ``convex_edge_normals``. Every point of the pixel lies within ``margin`` (half the pixel diagonal) of the pixel center, so the pixel is wholly inside the polygon when the pixel center's smallest signed edge distance exceeds ``margin``, and wholly outside when it is below ``-margin``. Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges. edge_nx, edge_ny, edge_c : double * The per-edge unit inward normals and offsets from ``convex_edge_normals``. n_poly : int The number of polygon vertices. margin : double Half the pixel diagonal. Returns ------- result : int 1 if the pixel is wholly inside the polygon, -1 if it is wholly outside, and 0 if it is in the boundary band. """ cdef double pxcen, pycen, min_dist, d cdef int k pxcen = 0.5 * (pxmin + pxmax) pycen = 0.5 * (pymin + pymax) min_dist = edge_nx[0] * pxcen + edge_ny[0] * pycen - edge_c[0] for k in range(1, n_poly): d = edge_nx[k] * pxcen + edge_ny[k] * pycen - edge_c[k] if d < min_dist: min_dist = d if min_dist > margin: return 1 if min_dist < -margin: return -1 return 0 cdef double convex_polygon_pixel_overlap(double pxmin, double pymin, double pxmax, double pymax, double *poly_x, double *poly_y, int n_poly, double *edge_nx, double *edge_ny, double *edge_c, int is_convex, double margin, double *buf_a_x, double *buf_a_y, double *buf_b_x, double *buf_b_y, int buf_size) noexcept nogil: """ Exact area of overlap between the pixel rectangle and a simple polygon, with an interior/exterior fast path for convex polygons. When ``is_convex`` is 1, a pixel is wholly inside the polygon when the pixel center's smallest signed edge distance exceeds ``margin`` (overlap = pixel area), and wholly outside when it is below ``-margin`` (overlap = 0), because every point of the pixel lies within ``margin`` of the pixel center. Pixels in the boundary band, and all pixels of nonconvex polygons, fall back to the exact Sutherland-Hodgman clip (``polygon_pixel_overlap``). Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges. poly_x, poly_y : double * The x and y coordinates of the polygon vertices, in counterclockwise order. n_poly : int The number of polygon vertices. edge_nx, edge_ny, edge_c : double * The per-edge unit inward normals and offsets from ``convex_edge_normals``. Used only when ``is_convex`` is 1. is_convex : int Whether the polygon is convex (see ``convex_edge_normals``). margin : double Half the pixel diagonal. buf_a_x, buf_a_y, buf_b_x, buf_b_y : double * Caller-allocated scratch buffers passed through to ``polygon_pixel_overlap`` (see that function for sizing). buf_size : int The size of the scratch buffers. Returns ------- area : double The area of overlap between the pixel and the polygon. """ cdef int position if is_convex: position = convex_pixel_position(pxmin, pymin, pxmax, pymax, edge_nx, edge_ny, edge_c, n_poly, margin) if position > 0: return (pxmax - pxmin) * (pymax - pymin) if position < 0: return 0.0 return polygon_pixel_overlap(pxmin, pymin, pxmax, pymax, poly_x, poly_y, n_poly, buf_a_x, buf_a_y, buf_b_x, buf_b_y, buf_size) cdef double convex_polygon_pixel_subpixel(double pxmin, double pymin, double pxmax, double pymax, double *poly_x, double *poly_y, int n_poly, double *edge_nx, double *edge_ny, double *edge_c, int is_convex, double margin, int subpixels, double *xint_buf, double *hxmin_buf, double *hxmax_buf) noexcept nogil: """ Fraction of overlap between a simple polygon and a single pixel using subpixel sampling, with an interior/exterior fast path for convex polygons. The fast path is exact for the sampled fraction, not an approximation. Every subpixel center lies strictly within ``margin`` of the pixel center, so a pixel that is wholly inside the polygon (see ``convex_pixel_position``) has every subpixel center strictly inside (sampled fraction exactly 1), and a wholly outside pixel has none (sampled fraction exactly 0). Pixels in the boundary band, and all pixels of nonconvex polygons, fall back to the scanline sampling (``polygon_overlap_single_subpixel``), so the result is identical to sampling every pixel. Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges. poly_x, poly_y : double * The x and y coordinates of the polygon vertices, in counterclockwise order. n_poly : int The number of polygon vertices. edge_nx, edge_ny, edge_c : double * The per-edge unit inward normals and offsets from ``convex_edge_normals``. Used only when ``is_convex`` is 1. is_convex : int Whether the polygon is convex (see ``convex_edge_normals``). margin : double Half the pixel diagonal. subpixels : int The number of subpixels to sample in each dimension. xint_buf, hxmin_buf, hxmax_buf : double * Caller-supplied scratch buffers passed through to ``polygon_overlap_single_subpixel`` (see that function for sizing). Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the polygon. """ cdef int position if is_convex: position = convex_pixel_position(pxmin, pymin, pxmax, pymax, edge_nx, edge_ny, edge_c, n_poly, margin) if position > 0: return 1.0 if position < 0: return 0.0 return polygon_overlap_single_subpixel(pxmin, pymin, pxmax, pymax, poly_x, poly_y, n_poly, subpixels, xint_buf, hxmin_buf, hxmax_buf) cdef int point_in_polygon(double x, double y, double *poly_x, double *poly_y, int n_poly) noexcept nogil: """ Test whether a point lies inside a simple polygon, using the standard ray-casting algorithm. The polygon may be convex or nonconvex. The on-edge test is performed first. If ``(x, y)`` lies on any edge (within the closed bounding box of that edge and collinear with it), the point is reported as outside. Otherwise the standard ray-casting parity test is applied for strictly interior/exterior points. Points lying exactly on a polygon edge are classified as *outside*. This matches the ``center`` method of the circular, elliptical, and rectangular apertures, which use a strict inequality (``< r**2``, ``< 1``, and ``< half_width/half_height``, respectively) and therefore also exclude every on-boundary point. In particular, an unrotated `~photutils.aperture.RectangularAperture` and a `~photutils.aperture.PolygonAperture` with the same four corners produce identical ``center`` masks. For a rotated rectangle the masks can differ at pixel centers that lie on an edge, because the two tests round differently. This boundary handling matters when sample points commonly fall exactly on an edge (e.g., pixel centers for integer-coordinate polygons). That is the case for the ``center`` method (``subpixels == 1``) and for subpixel sampling with an odd ``subpixels``, where the middle row and column of samples pass through the pixel center. Parameters ---------- x, y : double The coordinates of the point to test. poly_x, poly_y : double * The x and y coordinates of the polygon vertices, in counterclockwise order. n_poly : int The number of polygon vertices. Returns ------- result : int 1 if the point lies inside the polygon, 0 otherwise (including points exactly on the boundary). """ cdef int i, j, inside = 0 cdef double xi, yi, xj, yj cdef double cross if n_poly < 3: return 0 j = n_poly - 1 for i in range(n_poly): xi = poly_x[i] yi = poly_y[i] xj = poly_x[j] yj = poly_y[j] # Reject points exactly on this edge (collinear and within the # edge's closed bounding box), so that all boundary points are # classified as outside, consistent with the other apertures. cross = (xj - xi) * (y - yi) - (yj - yi) * (x - xi) if (cross == 0.0 and fmin(xi, xj) <= x <= fmax(xi, xj) and fmin(yi, yj) <= y <= fmax(yi, yj)): return 0 if ((yi > y) != (yj > y)): # x of intersection of polygon edge with horizontal line at y if x < (xj - xi) * (y - yi) / (yj - yi) + xi: inside = 1 - inside j = i return inside cdef double polygon_overlap_single_subpixel(double x0, double y0, double x1, double y1, double *poly_x, double *poly_y, int n_poly, int subpixels, double *xint_buf, double *hxmin_buf, double *hxmax_buf) noexcept nogil: """ Fraction of overlap between a simple polygon and a single pixel, using subpixel sampling. The pixel ``[x0, x1] x [y0, y1]`` is divided into ``subpixels ** 2`` subpixels and the fraction of subpixel centers that fall inside the polygon is returned. The polygon may be convex or nonconvex. For ``subpixels == 1`` (the ``center`` method) the single pixel-center sample is classified with the full ``point_in_polygon`` test, so that samples lying exactly on a polygon edge are excluded, consistent with the circular, elliptical, and rectangular apertures. For ``subpixels > 1`` the samples are classified with a scanline parity fill. For each subpixel row, the polygon boundary restricted to the horizontal line at ``y`` is fully characterized once (``O(n_poly)``) as a set of crossing points (from sloped and vertical edges) plus a set of closed x-intervals (from any horizontal edges lying exactly on that line). The crossings are sorted and the row's samples are then classified by a single monotonic sweep instead of retesting every edge for every sample. This reduces the per-pixel cost from ``O(subpixels ** 2 * n_poly)`` to roughly ``O(subpixels * n_poly + subpixels ** 2)`` while matching the classification of ``point_in_polygon``. Interior samples are classified by the identical ``x < xint`` parity rule, and on-boundary samples are excluded (classified as outside). A sample is on the boundary when its x coincides exactly with a crossing of a sloped or vertical edge, when it lies within the closed x-span of a horizontal edge at that ``y``, or when it coincides with a vertex. The exclusion applies only to samples whose computed coordinates match exactly. Sample coordinates are accumulated in floating point and the crossing of a sloped edge is subject to rounding, so a sample that is nominally on an edge can land one rounding step to either side and is then classified by the parity rule. Parameters ---------- x0, y0, x1, y1 : double The lower and upper edges of the pixel. poly_x, poly_y : double * The x and y coordinates of the polygon vertices, in counterclockwise order. n_poly : int The number of polygon vertices. subpixels : int The number of subpixels to sample in each dimension. xint_buf, hxmin_buf, hxmax_buf : double * Caller-supplied scratch buffers, each holding at least ``n_poly`` doubles. ``xint_buf`` stores the per-row edge crossings, while ``hxmin_buf`` and ``hxmax_buf`` store the endpoints of the per-row horizontal-edge and on-line vertex intervals. The function keeps no other state, so it is thread-safe as long as each thread supplies its own buffers. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the polygon. """ cdef int _i, _j, e, prev, m, a, b, ptr, h, hh cdef double x, y, dx, dy, hits cdef double xi, yi, xj, yj, tmp cdef bint inside, has_vertex dx = (x1 - x0) / subpixels dy = (y1 - y0) / subpixels hits = 0.0 if subpixels == 1: # Center method: a single sample at the pixel center. Use # the full point-in-polygon test so that on-edge samples are # classified consistently with the other apertures (excluded). if point_in_polygon(x0 + 0.5 * dx, y0 + 0.5 * dy, poly_x, poly_y, n_poly) == 1: hits = 1.0 return hits # A sample can land exactly on a vertex only if the vertex is # inside the pixel. This is rare, so it is checked once per pixel # to keep the per-row vertex pass out of the common path. has_vertex = False for e in range(n_poly): if y0 < poly_y[e] < y1 and x0 < poly_x[e] < x1: has_vertex = True break # Subpixel sampling (subpixels > 1): scanline parity fill. y = y0 + 0.5 * dy for _i in range(subpixels): # Characterize the polygon boundary on the horizontal line # at ``y``. Sloped and vertical edges that straddle the line # each contribute one crossing point (``xint_buf``), using # the same edge-straddle test and intersection arithmetic # as the parity step of ``point_in_polygon``. Horizontal # edges lying exactly on the line cannot straddle it, # so they are recorded separately as closed x-intervals # (``hxmin_buf``/``hxmax_buf``). Together with the on-line # vertices recorded below, these sets describe the boundary on # the line and let on-boundary samples be excluded. m = 0 h = 0 prev = n_poly - 1 for e in range(n_poly): yi = poly_y[e] yj = poly_y[prev] if yi == y and yj == y: xi = poly_x[e] xj = poly_x[prev] hxmin_buf[h] = fmin(xi, xj) hxmax_buf[h] = fmax(xi, xj) h += 1 elif (yi > y) != (yj > y): xi = poly_x[e] xj = poly_x[prev] xint_buf[m] = (xj - xi) * (y - yi) / (yj - yi) + xi m += 1 prev = e # Record each vertex lying exactly on the line as a zero-width # interval so that a sample on it is excluded. The edges at # such a vertex may not straddle the line (a local maximum in # y) or may give a crossing that differs from the vertex x by # rounding. A vertex that ends a horizontal edge is already # covered, so each edge adds at most one interval and # ``n_poly`` entries suffice. if has_vertex: prev = n_poly - 1 for e in range(n_poly): if poly_y[e] == y and poly_y[prev] != y: hxmin_buf[h] = poly_x[e] hxmax_buf[h] = poly_x[e] h += 1 prev = e # Insertion sort the (typically few) crossings in ascending x. for a in range(1, m): tmp = xint_buf[a] b = a - 1 while b >= 0 and xint_buf[b] > tmp: xint_buf[b + 1] = xint_buf[b] b -= 1 xint_buf[b + 1] = tmp # Sweep the row's samples (monotonically increasing in x). A # sample is inside when an odd number of crossings lie strictly # to its right (the ``x < xint`` parity rule), unless it lies # on the boundary. On-boundary samples are excluded (classified # as outside), consistent with ``point_in_polygon``. A sample # whose x coincides exactly with a crossing lies on a sloped or # vertical edge, and a sample within a horizontal edge's closed # x-span lies on that edge. x = x0 + 0.5 * dx ptr = 0 for _j in range(subpixels): while ptr < m and xint_buf[ptr] < x: ptr += 1 inside = (((m - ptr) & 1) == 1 and not (ptr < m and xint_buf[ptr] == x)) if inside: for hh in range(h): if hxmin_buf[hh] <= x <= hxmax_buf[hh]: inside = False break if inside: hits += 1.0 x += dx y += dy return hits / (subpixels * subpixels) cdef inline double _polygon_signed_area(const double *xs, const double *ys, int n) noexcept nogil: """ Signed area of a polygon via the shoelace formula. Positive for counterclockwise vertices, negative for clockwise vertices. For a Sutherland-Hodgman-clipped polygon the magnitude is the area of the intersection even when the subject was nonconvex (any spurious "bridge" edges traverse zero area in pairs). Parameters ---------- xs, ys : double * The x and y coordinates of the polygon vertices. n : int The number of polygon vertices. Returns ------- area : double The signed area of the polygon. """ cdef double area = 0.0 cdef int i, j if n < 3: return 0.0 for i in range(n): j = i + 1 if j == n: j = 0 area += xs[i] * ys[j] - xs[j] * ys[i] return 0.5 * area cdef inline int _clip_against_axis(double *xs_in, double *ys_in, int n_in, int axis, double bound, int keep_greater, double *xs_out, double *ys_out) noexcept nogil: """ Sutherland-Hodgman clip of a polygon against an axis-aligned half-plane. Parameters ---------- xs_in, ys_in : double * The x and y coordinates of the input polygon vertices. n_in : int The number of input polygon vertices. axis : int 0 to clip on the x coordinate (the line ``x = bound``), 1 to clip on the y coordinate (the line ``y = bound``). bound : double The coordinate value of the clipping line. keep_greater : int 1 to keep points with coordinate >= bound, 0 to keep points with coordinate <= bound. xs_out, ys_out : double * Output. The x and y coordinates of the clipped polygon vertices. Each must point to a buffer large enough to hold the result. There are at most ``n_in + 1`` vertices for a convex input polygon. For a nonconvex one, ``2 * n_in`` is a safe upper bound (an entering transition emits two vertices and every other vertex emits at most one). Returns ------- n_out : int The number of vertices in the clipped polygon. """ cdef int i, n_out = 0 cdef double sx, sy, px, py, sc, pc, t cdef double sign if n_in == 0: return 0 if keep_greater == 1: sign = 1.0 else: sign = -1.0 sx = xs_in[n_in - 1] sy = ys_in[n_in - 1] if axis == 0: sc = sign * (sx - bound) else: sc = sign * (sy - bound) for i in range(n_in): px = xs_in[i] py = ys_in[i] if axis == 0: pc = sign * (px - bound) else: pc = sign * (py - bound) if pc >= 0.0: if sc < 0.0: # entering: emit intersection, then point t = sc / (sc - pc) xs_out[n_out] = sx + t * (px - sx) ys_out[n_out] = sy + t * (py - sy) n_out += 1 xs_out[n_out] = px ys_out[n_out] = py n_out += 1 elif sc >= 0.0: # leaving: emit intersection only t = sc / (sc - pc) xs_out[n_out] = sx + t * (px - sx) ys_out[n_out] = sy + t * (py - sy) n_out += 1 sx = px sy = py sc = pc return n_out cdef void _ensure_ccw(const double[::1] xs, const double[::1] ys, int n, double *out_x, double *out_y) noexcept nogil: """ Copy polygon vertices, reversing their order if necessary to ensure counterclockwise orientation. The orientation is determined from the sign of the signed area (see ``_polygon_signed_area``). Parameters ---------- xs, ys : double[::1] The x and y coordinates of the input polygon vertices, in either clockwise or counterclockwise order. n : int The number of polygon vertices. out_x, out_y : double * Output. The x and y coordinates of the polygon vertices, in counterclockwise order. Each must point to a buffer of at least ``n`` doubles. """ cdef double area = _polygon_signed_area(&xs[0], &ys[0], n) cdef int i, k if area >= 0.0: for i in range(n): out_x[i] = xs[i] out_y[i] = ys[i] else: for i in range(n): k = n - 1 - i out_x[i] = xs[k] out_y[i] = ys[k] astropy-photutils-24be529/photutils/geometry/circle_overlap.pxd000066400000000000000000000063501526452531300252100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, cdivision=True """ Declarations needed to cimport the circle overlap functions into other Cython files. These single-pixel functions are pure C math functions that are safe to call without the GIL. """ cdef double circle_overlap_single_subpixel(double x0, double y0, double x1, double y1, double r, int subpixels) noexcept nogil cdef double circle_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double r) noexcept nogil cdef inline double circle_frac_from_d2(double pxmin, double pymin, double pxmax, double pymax, double dx, double dy, double pixel_radius, double d2, double r, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps a circle of radius ``r`` centered on the origin, given the precomputed squared distance ``d2`` from the origin to the pixel center. This is the per-pixel decision core for the circular overlap, factored out of ``circular_overlap_grid`` so the interior/exterior fast path and the exact/subpixel dispatch live in a single, reusable place. Using the squared distance avoids a ``sqrt`` per pixel. The interior/exterior fast-path thresholds ``r Âą pixel_radius`` are simply squared. No bounding-box check is performed (the caller is responsible for it), so the same core can be shared by the circle and circular-annulus helpers to avoid recomputing ``d2`` for each boundary. Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges, relative to the circle center. dx, dy : double The pixel width and height. pixel_radius : double Half the pixel diagonal. d2 : double The squared distance from the circle center to the pixel center. r : double The radius of the circle. use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the circle. """ cdef double r_inner = r - pixel_radius cdef double r_outer = r + pixel_radius # Pixel is well within the circle if r_inner > 0.0 and d2 < r_inner * r_inner: return 1.0 if d2 < r_outer * r_outer: # pixel is close to the circle border if use_exact: return circle_overlap_single_exact(pxmin, pymin, pxmax, pymax, r) / (dx * dy) return circle_overlap_single_subpixel(pxmin, pymin, pxmax, pymax, r, subpixels) return 0.0 # pixel is fully outside the circle astropy-photutils-24be529/photutils/geometry/circle_overlap.pyx000066400000000000000000000256361526452531300252450ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Tools to calculate the area of overlap between a circle and a pixel grid. The cdef functions are not intended to be called from Python code. They are pure C math functions declared ``noexcept nogil`` so they can be called without the GIL, including from multiple threads on free-threaded Python builds. The signatures of the single-pixel functions are exported via circle_overlap.pxd for reuse elsewhere. NOTE: The ``circular_overlap_grid`` function should be named ``circle_overlap_grid``, but it has been public for a long time and changing the name would break backwards compatibility. """ import numpy as np cimport numpy as np from .core cimport area_arc, area_triangle, floor_sqrt __all__ = ['circular_overlap_grid'] cdef extern from "math.h" nogil: double sqrt(double x) DTYPE = np.float64 ctypedef np.float64_t DTYPE_t def circular_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, double r, int use_exact, int subpixels): """ Calculate the fractional overlap between a circle and a pixel grid. The circle is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float The extent of the grid in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax). nx, ny : int The grid dimensions in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax) and is divided into nx and ny pixels in the x and y direction, respectively. r : float The radius of the circle. use_exact : 0 or 1 Set to ``1`` to use an exact method to calculate the overlap between the circle and each pixel. Set to ``0`` to use a subpixel sampling method to calculate the overlap, where each pixel is divided into ``subpixels ** 2`` subpixels and the fraction of subpixels that are within the circle is used to estimate the overlap. subpixels : int The number of subpixels to use in each dimension when using the subpixel sampling method (``use_exact=0``). It must be at least 1 in that case. It is ignored when ``use_exact=1``. Each pixel is resampled by this factor in each dimension, so each pixel is divided into ``subpixels ** 2`` subpixels. A subpixel is included only if its center lies strictly inside the circle. Subpixel centers lying exactly on the circle boundary are excluded (weight 0). Returns ------- result : `~numpy.ndarray` (float) A 2D array of shape (ny, nx) giving the fraction of each pixel's area that overlaps with the circle, ranging from 0 to 1. The element at index (j, i) corresponds to the pixel with corners at (xmin + i * dx, ymin + j * dy) and (xmin + (i + 1) * dx, ymin + (j + 1) * dy), where dx and dy are the width of each pixel in the x and y direction, respectively. Raises ------ ValueError If ``use_exact`` is not 0 or 1, or if ``subpixels`` is less than 1 when ``use_exact=0``. """ if use_exact != 0 and use_exact != 1: msg = 'use_exact must be 0 or 1' raise ValueError(msg) if use_exact == 0 and subpixels < 1: msg = 'subpixels must be a strictly positive integer' raise ValueError(msg) cdef unsigned int i, j cdef double dx, dy, d2, 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) cdef double[:, ::1] frac_view = frac # 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 with nogil: 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: # Squared distance from circle center to pixel # center (avoids a per-pixel sqrt). d2 = pxcen * pxcen + pycen * pycen # Per-pixel decision core (interior/exterior # fast path and exact/subpixel dispatch), # factored out into ``circle_frac_from_d2`` # in circle_overlap.pxd. frac_view[j, i] = circle_frac_from_d2( pxmin, pymin, pxmax, pymax, dx, dy, pixel_radius, d2, r, use_exact, subpixels) return frac cdef double circle_overlap_single_subpixel(double x0, double y0, double x1, double y1, double r, int subpixels) noexcept nogil: """ Fraction of overlap between a circle of radius ``r`` centered on the origin and a single pixel, using subpixel sampling. Parameters ---------- x0, y0, x1, y1 : double The lower and upper edges of the pixel. r : double The radius of the circle. subpixels : int The number of subpixels to sample in each dimension. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the circle. """ 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 circle_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double r) noexcept nogil: """ Exact area of overlap between a circle centered on the origin and a single pixel. The pixel is split at the x- and y-axes as needed so that ``circle_overlap_core`` can be applied to a quadrant-aligned rectangle, and the partial areas are summed. Parameters ---------- xmin, ymin, xmax, ymax : double The lower and upper edges of the pixel. r : double The radius of the circle. Returns ------- area : double The area of overlap between the circle and the pixel. """ if 0.0 <= xmin: if 0.0 <= ymin: return circle_overlap_core(xmin, ymin, xmax, ymax, r) elif 0.0 >= ymax: return circle_overlap_core(-ymax, xmin, -ymin, xmax, r) else: return circle_overlap_single_exact(xmin, ymin, xmax, 0.0, r) \ + circle_overlap_single_exact(xmin, 0.0, xmax, ymax, r) elif 0.0 >= xmax: if 0.0 <= ymin: return circle_overlap_core(-xmax, ymin, -xmin, ymax, r) elif 0.0 >= ymax: return circle_overlap_core(-xmax, -ymax, -xmin, -ymin, r) else: return circle_overlap_single_exact(xmin, ymin, xmax, 0.0, r) \ + circle_overlap_single_exact(xmin, 0.0, xmax, ymax, r) else: if 0.0 <= ymin or 0.0 >= ymax: return circle_overlap_single_exact(xmin, ymin, 0.0, ymax, r) \ + circle_overlap_single_exact(0.0, ymin, xmax, ymax, r) else: return circle_overlap_single_exact(xmin, ymin, 0.0, 0.0, r) \ + circle_overlap_single_exact(0.0, ymin, xmax, 0.0, r) \ + circle_overlap_single_exact(xmin, 0.0, 0.0, ymax, r) \ + circle_overlap_single_exact(0.0, 0.0, xmax, ymax, r) cdef double circle_overlap_core(double xmin, double ymin, double xmax, double ymax, double r) noexcept nogil: """ Exact area of overlap between a circle centered on the origin and a rectangle confined to the first quadrant. This assumes ``0 <= xmin`` and ``0 <= ymin`` (the caller is responsible for splitting a pixel that straddles either axis, as done by ``circle_overlap_single_exact``). Parameters ---------- xmin, ymin, xmax, ymax : double The lower and upper edges of the rectangle, with ``0 <= xmin <= xmax`` and ``0 <= ymin <= ymax``. r : double The radius of the circle. Returns ------- area : double The area of overlap between the circle and the rectangle. """ cdef double area, x1, x2, y1, y2 cdef double r2 = r * r cdef double xmin2 = xmin * xmin cdef double ymin2 = ymin * ymin cdef double xmax2 = xmax * xmax cdef double ymax2 = ymax * ymax cdef double d1, d2 if xmin2 + ymin2 > r2: area = 0.0 elif xmax2 + ymax2 < r2: area = (xmax - xmin) * (ymax - ymin) else: area = 0.0 # Squared distances of the other two corners, compared with the # squared radius so that no square root is needed to classify # the pixel d1 = xmax2 + ymin2 d2 = xmin2 + ymax2 if d1 < r2 and d2 < r2: x1, y1 = floor_sqrt(r2 - ymax2), ymax x2, y2 = xmax, floor_sqrt(r2 - xmax2) area = ((xmax - xmin) * (ymax - ymin) - area_triangle(x1, y1, x2, y2, xmax, ymax) + area_arc(x1, y1, x2, y2, r)) elif d1 < r2: x1, y1 = xmin, floor_sqrt(r2 - xmin2) x2, y2 = xmax, floor_sqrt(r2 - xmax2) 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 < r2: x1, y1 = floor_sqrt(r2 - ymin2), ymin x2, y2 = floor_sqrt(r2 - ymax2), 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(r2 - ymin2), ymin x2, y2 = xmin, floor_sqrt(r2 - xmin2) area = (area_arc(x1, y1, x2, y2, r) + area_triangle(x1, y1, x2, y2, xmin, ymin)) return area astropy-photutils-24be529/photutils/geometry/core.pxd000066400000000000000000000073301526452531300231460ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, cdivision=True """ Declarations needed to cimport the core geometry functions into other Cython files. All functions are pure C math functions that are safe to call without the GIL. The small helpers used in per-pixel hot loops are defined here as ``cdef inline`` functions so that the C compiler can inline them into the modules that cimport them. """ cdef extern from "math.h" nogil: double asin(double x) double atan2(double y, double x) double sin(double x) double sqrt(double x) double fabs(double x) double fmin(double x, double y) cdef inline double floor_sqrt(double x) noexcept nogil: """ Square root of a value expected to be nonnegative, treating small negative values (e.g., from floating-point roundoff) as zero. This does not check whether a negative value is actually close to zero, so it should be used only where the true value is expected to be nonnegative. Parameters ---------- x : double The value to take the square root of. Returns ------- result : double ``sqrt(x)`` if ``x > 0``, otherwise 0. """ if x > 0: return sqrt(x) else: return 0 cdef inline double distance(double x1, double y1, double x2, double y2) noexcept nogil: """ Distance between two points in two dimensions. Parameters ---------- x1, y1 : double The coordinates of the first point. x2, y2 : double The coordinates of the second point. Returns ------- d : double The Euclidean distance between the two points. """ return sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) cdef inline double area_arc(double x1, double y1, double x2, double y2, double r) noexcept nogil: """ Area of a circular segment, the region between the chord connecting (x1, y1) and (x2, y2) and the circular arc of radius ``r`` spanning the same two points. Both points are assumed to lie on a circle of radius ``r`` centered on the origin. Parameters ---------- x1, y1, x2, y2 : double The coordinates of the two points defining the chord. r : double The radius of the circle. Returns ------- area : double The area of the circular segment. References ---------- http://mathworld.wolfram.com/CircularSegment.html """ cdef double a, theta a = distance(x1, y1, x2, y2) # Clamp the half-chord ratio to 1 to guard against floating-point # roundoff pushing a near-diameter chord past the domain of asin, # which would return NaN. theta = 2.0 * asin(fmin(1.0, 0.5 * a / r)) return 0.5 * r * r * (theta - sin(theta)) cdef inline double area_triangle(double x1, double y1, double x2, double y2, double x3, double y3) noexcept nogil: """ Area of a triangle defined by three vertices. Parameters ---------- x1, y1, x2, y2, x3, y3 : double The coordinates of the three vertices. Returns ------- area : double The (unsigned) area of the triangle. """ return 0.5 * fabs(x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) cdef double area_arc_unit(double x1, double y1, double x2, double y2) noexcept nogil cdef int in_triangle(double x, double y, double x1, double y1, double x2, double y2, double x3, double y3) noexcept nogil cdef double overlap_area_triangle_unit_circle(double x1, double y1, double x2, double y2, double x3, double y3) noexcept nogil astropy-photutils-24be529/photutils/geometry/core.pyx000066400000000000000000000422511526452531300231740ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Core geometry functions for computing the overlap of an aperture and a pixel grid. The cdef functions are not intended to be called from Python code. They are pure C math functions declared ``noexcept nogil`` so they can be called without the GIL, including from multiple threads on free-threaded Python builds. The signatures of the functions used by other modules are exported via core.pxd. """ # The math functions used here are declared in core.pxd. cdef extern from "math.h" nogil: double M_PI double NAN ctypedef struct point: double x double y ctypedef struct intersections: point p1 point p2 cdef double area_arc_unit(double x1, double y1, double x2, double y2) noexcept nogil: """ Area of a circular segment of the unit circle (radius 1), the region between the chord connecting (x1, y1) and (x2, y2) and the circular arc spanning the same two points. Both points are assumed to lie on the unit circle centered on the origin. This is equivalent to ``area_arc`` with ``r = 1``. Parameters ---------- x1, y1, x2, y2 : double The coordinates of the two points defining the chord. Returns ------- area : double The area of the circular segment. References ---------- http://mathworld.wolfram.com/CircularSegment.html """ cdef double chord # The chord length is twice the sine of half the angle that the # chord subtends, but recovering the angle from it with an arcsine # loses half of the precision when the chord is nearly a diameter. # The sine and cosine of the full angle are the cross and dot # products of the two points, which atan2 handles accurately for # any chord. chord = fabs(x1 * y2 - y1 * x2) return 0.5 * (atan2(chord, x1 * x2 + y1 * y2) - chord) cdef int in_triangle(double x, double y, double x1, double y1, double x2, double y2, double x3, double y3) noexcept nogil: """ Test whether a point lies inside a triangle. Parameters ---------- x, y : double The coordinates of the point to test. x1, y1, x2, y2, x3, y3 : double The coordinates of the triangle's three vertices. Returns ------- result : int 1 if the point lies inside the triangle, 0 otherwise. """ 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) noexcept nogil: """ Intersection of the infinite line through two points with the unit circle. Parameters ---------- x1, y1, x2, y2 : double The coordinates of two distinct points defining the line. Returns ------- result : intersections The two intersection points, ``p1`` and ``p2``. Coordinates greater than 1 indicate that there is no intersection. """ cdef double a, b, delta, dx, dy cdef double tolerance = 1.e-10 cdef intersections inter dx = x2 - x1 dy = y2 - y1 # Initialize to values > 1, indicating no intersection inter.p1.x = 2. inter.p1.y = 2. inter.p2.x = 2. inter.p2.y = 2. if fabs(dx) < tolerance and fabs(dy) < tolerance: return inter if fabs(dx) > fabs(dy): # Find the slope and intercept of the line a = dy / dx b = y1 - a * x1 # Find the (reduced) discriminant 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: # Find the slope and intercept of the line a = dx / dy b = x1 - a * y1 # Find the (reduced) discriminant 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 return inter cdef point circle_segment_single2(double x1, double y1, double x2, double y2) noexcept nogil: """ Intersection of the infinite line through two points with the unit circle, choosing the intersection closest to the second point. Parameters ---------- x1, y1, x2, y2 : double The coordinates of two distinct points defining the line. Returns ------- result : point The intersection point closest to ``(x2, y2)``. """ 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) noexcept nogil: """ Intersection(s) of a line segment with the unit circle. Parameters ---------- x1, y1, x2, y2 : double The coordinates of the segment's two endpoints. Returns ------- result : intersections The two intersection points, ``p1`` and ``p2``. Any solution that does not lie on the segment is discarded (coordinates set to 2, which is outside the unit circle). """ 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. # Order the output so that p1 is invalid (coordinates > 1) whenever # fewer than two intersections lie on the segment. Callers pass # segments whose endpoints are both outside the circle and test p1 # alone to decide whether a full chord exists. For such segments a # single on-segment intersection is a degenerate grazing case and # is treated as no intersection. 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 point chord_end(double x1, double y1, double x2, double y2) noexcept nogil: """ End point of the chord of the unit circle that starts at a point on the circle and is directed toward a second point. Because the first point is on the circle, the other intersection of the line with the circle has a closed form that needs no square root. It therefore stays well defined when the line is tangent to the circle, where the general line intersection can lose its solutions to floating-point roundoff. Parameters ---------- x1, y1 : double The coordinates of the point on the unit circle. x2, y2 : double The coordinates of the point that defines the direction. Returns ------- result : point The other end of the chord. This is ``(x1, y1)`` itself if the direction is tangent to the circle or points away from it. """ cdef double dx, dy, t cdef point pt dx = x2 - x1 dy = y2 - y1 pt.x = x1 pt.y = y1 t = -2.0 * (x1 * dx + y1 * dy) if t > 0.0: t /= dx * dx + dy * dy pt.x += t * dx pt.y += t * dy return pt cdef double overlap_area_vertex_on_unit_circle(double x1, double y1, double x2, double y2, double x3, double y3) noexcept nogil: """ Area of overlap between a triangle and the unit circle when the first vertex lies on the circle and the other two lie outside it. The two edges that meet at the first vertex bound a wedge. Each edge that points into the circle is a chord, described by the angle ``phi`` between the edge and the tangent to the circle at the vertex. That chord cuts off a circular segment of area ``phi - sin(phi) * cos(phi)``, so the area of the circle inside the wedge is the difference of the two segment areas. An edge that points away from the circle is replaced by the tangent direction that the wedge sweeps across. The part of the circle beyond the opposite edge, if any, is then removed. Parameters ---------- x1, y1 : double The coordinates of the vertex on the unit circle. x2, y2, x3, y3 : double The coordinates of the two vertices outside the unit circle. Returns ------- area : double The area of overlap between the triangle and the unit circle. """ cdef double dx2, dy2, dx3, dy3 cdef double n2, n3, c2, c3, cross cdef double f2, f3, area, cap, side0, side1 cdef intersections inter dx2 = x2 - x1 dy2 = y2 - y1 dx3 = x3 - x1 dy3 = y3 - y1 # Components of each edge along the inward normal of the circle n2 = -(x1 * dx2 + y1 * dy2) n3 = -(x1 * dx3 + y1 * dy3) if n2 <= 0.0 and n3 <= 0.0: # Neither edge enters the circle, so the triangle lies on the # outer side of the tangent line. return 0.0 # Components of each edge along the tangent of the circle c2 = x1 * dy2 - y1 * dx2 c3 = x1 * dy3 - y1 * dx3 # Orientation of the wedge, which determines the tangent direction # it sweeps across when only one edge enters the circle cross = dx3 * dy2 - dy3 * dx2 if n2 > 0.0: f2 = atan2(n2, c2) - n2 * c2 / (dx2 * dx2 + dy2 * dy2) elif cross < 0.0: f2 = 0.0 else: f2 = M_PI if n3 > 0.0: f3 = atan2(n3, c3) - n3 * c3 / (dx3 * dx3 + dy3 * dy3) elif cross > 0.0: f3 = 0.0 else: f3 = M_PI area = fabs(f2 - f3) # Remove the circular segment beyond the opposite edge inter = circle_segment(x2, y2, x3, y3) if inter.p1.x <= 1.: cap = area_arc_unit(inter.p1.x, inter.p1.y, inter.p2.x, inter.p2.y) # The segment is the larger of the two cut by the chord when # the opposite edge separates the first vertex from the circle # center. side0 = (y3 - y2) * x2 - (x3 - x2) * y2 side1 = (x3 - x2) * (y1 - y2) - (y3 - y2) * (x1 - x2) if (side0 > 0.0) != (side1 > 0.0): cap = M_PI - cap area -= cap return area cdef double overlap_area_triangle_unit_circle(double x1, double y1, double x2, double y2, double x3, double y3) noexcept nogil: """ Area of overlap between a triangle and the unit circle centered on the origin. Parameters ---------- x1, y1, x2, y2, x3, y3 : double The coordinates of the triangle's three vertices. Returns ------- area : double The area of overlap between the triangle and the unit circle. """ cdef double d1, d2, d3 cdef bint in1, in2, in3 cdef bint on1, on2, on3 cdef bint cond1, cond2 cdef double area, xp, yp cdef intersections inter cdef point pt1, pt2, pt3, pt4, pt5, pt6 # Find the squared distance of each vertex from the 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: # This should never happen. Return NaN to signal an internal # logic error without requiring the GIL to raise an exception. return NAN # Determine which vertices are inside the 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: # Vertex 3 is outside the circle. Find the point where each # edge to vertex 3 leaves the circle. For a vertex on the # circle this is the end of the chord along that edge, which is # the vertex itself if the edge does not enter the circle. if on1: pt1 = chord_end(x1, y1, x3, y3) else: pt1 = circle_segment_single2(x1, y1, x3, y3) if on2: pt2 = chord_end(x2, y2, x3, y3) else: 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 on1: # Vertices 2 and 3 are outside the circle, but the triangle # can still cross it. area = overlap_area_vertex_on_unit_circle(x1, y1, x2, y2, x3, y3) 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 from `sep.h` cond1 = ((0. - pt3.y) * (pt4.x - pt3.x) > (pt4.y - pt3.y) * (0. - pt3.x)) cond2 = ((y1 - pt3.y) * (pt4.x - pt3.x) > (pt4.y - pt3.y) * (x1 - pt3.x)) if cond1 != cond2: area = (area_triangle(x1, y1, pt3.x, pt3.y, pt4.x, pt4.y) + (M_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 M_PI else: return 0. return area astropy-photutils-24be529/photutils/geometry/ellipse_overlap.pxd000066400000000000000000000140421526452531300254010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, cdivision=True """ Declarations needed to cimport the ellipse overlap functions into other Cython files. These single-pixel functions are pure C math functions that are safe to call without the GIL. """ cdef double ellipse_overlap_single_subpixel(double x0, double y0, double x1, double y1, double rx, double ry, double cos_theta, double sin_theta, int subpixels) noexcept nogil cdef double ellipse_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double rx, double ry, double cos_theta, double sin_theta) noexcept nogil cdef extern from "math.h" nogil: double sqrt(double x) double fmin(double x, double y) cdef inline void ellipse_quadratic_coeffs(double rx, double ry, double cos_theta, double sin_theta, double dx, double dy, double *cxx, double *cyy, double *cxy, double *f_in, double *f_out) noexcept nogil: """ Compute the quadratic-form coefficients and interior/exterior fast-path thresholds of an ellipse with semiaxes ``rx``/``ry`` and orientation ``cos_theta``/``sin_theta``, for a pixel grid of spacing ``dx``/``dy``. A point (x, y) lies inside the ellipse when ``cxx*x**2 + cyy*y**2 + cxy*x*y < 1``. A pixel is wholly inside when its center value is ``<= f_in`` and wholly outside when it is ``>= f_out`` (see ``ellipse_frac_from_rpix2``). When half the pixel diagonal is at least the smaller semiaxis, the center test cannot establish that a pixel is wholly inside, and ``f_in`` is set to -1 so the interior fast path never triggers (the center value is always >= 0). The results are returned through the output pointers so the same setup can be computed once and reused wherever it is needed. Parameters ---------- rx, ry : double The semimajor and semiminor axes of the ellipse. cos_theta, sin_theta : double The cosine and sine of the ellipse's position angle. dx, dy : double The pixel width and height. cxx, cyy, cxy : double * Output. The quadratic-form coefficients. f_in, f_out : double * Output. The interior and exterior fast-path thresholds. """ cdef double inv_rx2 = 1.0 / (rx * rx) cdef double inv_ry2 = 1.0 / (ry * ry) cdef double margin, fin cxx[0] = cos_theta * cos_theta * inv_rx2 + sin_theta * sin_theta * inv_ry2 cyy[0] = sin_theta * sin_theta * inv_rx2 + cos_theta * cos_theta * inv_ry2 cxy[0] = 2.0 * cos_theta * sin_theta * (inv_rx2 - inv_ry2) margin = 0.5 * sqrt(dx * dx + dy * dy) / fmin(rx, ry) fin = 1.0 - margin # Use a negative sentinel when the center test cannot establish # that a pixel is wholly inside. # Clamping to 0 would misclassify a pixel whose center lies exactly # on the ellipse center (value 0) as wholly inside. f_in[0] = fin * fin if fin > 0.0 else -1.0 f_out[0] = (1.0 + margin) * (1.0 + margin) cdef inline double ellipse_frac_from_rpix2(double pxmin, double pymin, double pxmax, double pymax, double norm, double rx, double ry, double cos_theta, double sin_theta, double rpix2, double f_in, double f_out, int use_exact, int subpixels) noexcept nogil: """ Fraction of a single pixel that overlaps an ellipse, given the precomputed quadratic-form value ``rpix2`` at the pixel center and the fast-path thresholds ``f_in``/``f_out``. This is the per-pixel decision core for the elliptical overlap (interior/exterior fast path and exact/subpixel dispatch), factored out of ``elliptical_overlap_grid`` so the same logic can be reused by the ellipse and elliptical-annulus helpers. No bounding-box check is performed (the caller is responsible for it). Parameters ---------- pxmin, pymin, pxmax, pymax : double The pixel edges, relative to the ellipse center. norm : double The inverse pixel area, used to normalize the exact overlap calculation. rx, ry : double The semimajor and semiminor axes of the ellipse. cos_theta, sin_theta : double The cosine and sine of the ellipse's position angle. rpix2 : double The quadratic-form value at the pixel center (see ``ellipse_quadratic_coeffs``). f_in, f_out : double The interior and exterior fast-path thresholds (see ``ellipse_quadratic_coeffs``). use_exact : int Set to 1 to use the exact geometric overlap calculation. Set to 0 to use subpixel sampling instead. subpixels : int The number of subpixels (per dimension) used when ``use_exact`` is 0. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the ellipse. """ if rpix2 >= f_out: return 0.0 # pixel fully outside the ellipse if rpix2 <= f_in: return 1.0 # pixel fully inside the ellipse if use_exact: return ellipse_overlap_single_exact(pxmin, pymin, pxmax, pymax, rx, ry, cos_theta, sin_theta) * norm return ellipse_overlap_single_subpixel(pxmin, pymin, pxmax, pymax, rx, ry, cos_theta, sin_theta, subpixels) astropy-photutils-24be529/photutils/geometry/ellipse_overlap.pyx000066400000000000000000000252401526452531300254300ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Tools to calculate the area of overlap between an ellipse and a pixel grid. The method divides the pixel rectangle into two triangles and reprojects them into a coordinate system where the ellipse becomes the unit circle. It then computes the intersection area between each triangle and the unit circle. The cdef functions are not intended to be called from Python code. They are pure C math functions declared ``noexcept nogil`` so they can be called without the GIL, including from multiple threads on free-threaded Python builds. Their signatures are exported via ellipse_overlap.pxd for reuse elsewhere. NOTE: The ``elliptical_overlap_grid`` function should be named ``ellipse_overlap_grid``, but it has been public for a long time and changing the name would break backwards compatibility. """ import numpy as np cimport numpy as np from .core cimport overlap_area_triangle_unit_circle __all__ = ['elliptical_overlap_grid'] cdef extern from "math.h" nogil: double sin(double x) double cos(double x) DTYPE = np.float64 ctypedef np.float64_t DTYPE_t 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): """ Calculate the fractional overlap between an ellipse and a pixel grid. The ellipse is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float The extent of the grid in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax). nx, ny : int The grid dimensions in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax) and is divided into nx and ny pixels in the x and y direction, respectively. rx : float The length of the semiaxis of the ellipse along the direction given by ``theta``. This is the semimajor axis when ``rx >= ry``. ry : float The length of the semiaxis of the ellipse perpendicular to the direction given by ``theta``. theta : float The rotation angle of the ``rx`` axis in radians, measured counterclockwise from the positive x-axis. use_exact : 0 or 1 Set to ``1`` to use an exact method to calculate the overlap between the ellipse and each pixel. Set to ``0`` to use a subpixel sampling method to calculate the overlap, where each pixel is divided into ``subpixels ** 2`` subpixels and the fraction of subpixels that are within the ellipse is used to estimate the overlap. subpixels : int The number of subpixels to use in each dimension when using the subpixel sampling method (``use_exact=0``). It must be at least 1 in that case. It is ignored when ``use_exact=1``. Each pixel is resampled by this factor in each dimension, so each pixel is divided into ``subpixels ** 2`` subpixels. A subpixel is included only if its center lies strictly inside the ellipse. Subpixel centers lying exactly on the ellipse boundary are excluded (weight 0). Returns ------- result : `~numpy.ndarray` (float) A 2D array of shape (ny, nx) giving the fraction of each pixel's area that overlaps with the ellipse, ranging from 0 to 1. The element at index (j, i) corresponds to the pixel with corners at (xmin + i * dx, ymin + j * dy) and (xmin + (i + 1) * dx, ymin + (j + 1) * dy), where dx and dy are the width of each pixel in the x and y direction, respectively. Raises ------ ValueError If ``use_exact`` is not 0 or 1, or if ``subpixels`` is less than 1 when ``use_exact=0``. """ if use_exact != 0 and use_exact != 1: msg = 'use_exact must be 0 or 1' raise ValueError(msg) if use_exact == 0 and subpixels < 1: msg = 'subpixels must be a strictly positive integer' raise ValueError(msg) cdef unsigned int i, j cdef double dx, dy cdef double bx, by cdef double bxmin, bxmax, bymin, bymax cdef double pxmin, pxmax, pymin, pymax cdef double pxcen, pycen, rpix2 cdef double cos_theta, sin_theta cdef double cxx, cyy, cxy, f_in, f_out cdef double norm # Define output array cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) cdef double[:, ::1] frac_view = frac # Find the width of each element in x and y dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny norm = 1.0 / (dx * dy) # Quadratic-form coefficients and interior/exterior fast-path # thresholds, computed once here via ``ellipse_quadratic_coeffs``. cos_theta = cos(theta) sin_theta = sin(theta) ellipse_quadratic_coeffs(rx, ry, cos_theta, sin_theta, dx, dy, &cxx, &cyy, &cxy, &f_in, &f_out) # Tight axis-aligned bounding box of the rotated ellipse. The # half-extents are the maxima of |x| and |y| over the ellipse. bx = sqrt(rx * rx * cos_theta * cos_theta + ry * ry * sin_theta * sin_theta) by = sqrt(rx * rx * sin_theta * sin_theta + ry * ry * cos_theta * cos_theta) # Define bounding box bxmin = -bx - 0.5 * dx bxmax = +bx + 0.5 * dx bymin = -by - 0.5 * dy bymax = +by + 0.5 * dy with nogil: for i in range(nx): pxmin = xmin + i * dx # lower end of pixel pxmax = pxmin + dx # upper end of pixel pxcen = pxmin + 0.5 * dx if pxmax > bxmin and pxmin < bxmax: for j in range(ny): pymin = ymin + j * dy pymax = pymin + dy if pymax > bymin and pymin < bymax: pycen = pymin + 0.5 * dy rpix2 = (cxx * pxcen * pxcen + cyy * pycen * pycen + cxy * pxcen * pycen) # Per-pixel decision core, factored out into # ``ellipse_frac_from_rpix2`` in # ellipse_overlap.pxd. frac_view[j, i] = ellipse_frac_from_rpix2( pxmin, pymin, pxmax, pymax, norm, rx, ry, cos_theta, sin_theta, rpix2, f_in, f_out, use_exact, subpixels) return frac cdef double ellipse_overlap_single_subpixel(double x0, double y0, double x1, double y1, double rx, double ry, double cos_theta, double sin_theta, int subpixels) noexcept nogil: """ Fraction of overlap between an ellipse centered on the origin and a single pixel, using subpixel sampling. Parameters ---------- x0, y0, x1, y1 : double The lower and upper edges of the pixel. rx, ry : double The semimajor and semiminor axes of the ellipse. cos_theta, sin_theta : double The cosine and sine of the ellipse's position angle, precomputed by the caller. subpixels : int The number of subpixels to sample in each dimension. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the ellipse. """ 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 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 ellipse_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double rx, double ry, double cos_theta, double sin_theta) noexcept nogil: """ Exact area of overlap between an ellipse centered on the origin and a rectangle. The method divides the pixel rectangle into two triangles and reprojects them into a coordinate system where the ellipse becomes the unit circle. It then computes the intersection area between each triangle and the unit circle, and sums the two areas to get the total area of overlap. Parameters ---------- xmin, ymin, xmax, ymax : double The lower and upper edges of the rectangle. rx, ry : double The semimajor and semiminor axes of the ellipse. cos_theta, sin_theta : double The cosine and sine of the ellipse's position angle, precomputed by the caller. Returns ------- area : double The area of overlap between the ellipse and the rectangle. """ # The reprojection uses the -theta rotation, for which # cos(-theta) = cos(theta) and sin(-theta) = -sin(theta). cdef double cos_m_theta = cos_theta cdef double sin_m_theta = -sin_theta cdef double scale cdef double x1, y1, x2, y2, x3, y3, x4, y4 # 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-24be529/photutils/geometry/rectangle_overlap.pxd000066400000000000000000000044641526452531300257170ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, cdivision=True """ Declarations needed to cimport the rectangle overlap function into other Cython files. This single-pixel function is a pure C math function that is safe to call without the GIL. """ cdef double rectangle_overlap_single_subpixel(double x0, double y0, double x1, double y1, double half_width, double half_height, double cos_theta, double sin_theta, int subpixels) noexcept nogil cdef inline void rect_vertices(double half_width, double half_height, double cos_theta, double sin_theta, double *poly_x, double *poly_y) noexcept nogil: """ Build the four counterclockwise vertices of a rotated rectangle centered on the origin. Factored out of ``rectangular_overlap_grid`` so the vertex arithmetic lives in one reusable place. The local-frame vertices in counterclockwise order are (-w/2, -h/2), (w/2, -h/2), (w/2, h/2), (-w/2, h/2), rotated by theta: ``x' = x cos t - y sin t``, ``y' = x sin t + y cos t``. Parameters ---------- half_width, half_height : double Half the width and half the height of the rectangle. cos_theta, sin_theta : double The cosine and sine of the rectangle's rotation angle. poly_x, poly_y : double * Output. The x and y coordinates of the four rectangle vertices, in counterclockwise order. Each must point to a buffer of at least 4 doubles. """ poly_x[0] = -half_width * cos_theta - (-half_height) * sin_theta poly_y[0] = -half_width * sin_theta + (-half_height) * cos_theta poly_x[1] = half_width * cos_theta - (-half_height) * sin_theta poly_y[1] = half_width * sin_theta + (-half_height) * cos_theta poly_x[2] = half_width * cos_theta - half_height * sin_theta poly_y[2] = half_width * sin_theta + half_height * cos_theta poly_x[3] = -half_width * cos_theta - half_height * sin_theta poly_y[3] = -half_width * sin_theta + half_height * cos_theta astropy-photutils-24be529/photutils/geometry/rectangle_overlap.pyx000066400000000000000000000264211526452531300257410ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Tools to calculate the area of overlap between a rectangle and a pixel grid. The cdef function is not intended to be called from Python code. It is a pure C math function declared ``noexcept nogil`` so it can be called without the GIL, including from multiple threads on free-threaded Python builds. Its signature is exported via rectangle_overlap.pxd for reuse elsewhere. NOTE: The ``rectangular_overlap_grid`` function should be named ``rectangle_overlap_grid``, but it has been public for a long time and changing the name would break backwards compatibility. """ import numpy as np cimport numpy as np from ._polygon_overlap cimport polygon_pixel_overlap __all__ = ['rectangular_overlap_grid'] cdef extern from "math.h" nogil: double sin(double x) double cos(double x) double sqrt(double x) double fabs(double x) double fmax(double x, double y) double fmin(double x, double y) double floor(double x) double ceil(double x) DTYPE = np.float64 ctypedef np.float64_t DTYPE_t 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): """ Calculate the fractional overlap between a rectangle and a pixel grid. The rectangle is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float The extent of the grid in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax). nx, ny : int The grid dimensions in the x and y direction. The grid is defined by the rectangle with corners (xmin, ymin) and (xmax, ymax) and is divided into nx and ny pixels in the x and y direction, respectively. width : float The full extent of the rectangle along its rotated x-axis (the direction given by ``theta``). height : float The full extent of the rectangle perpendicular to ``width``. theta : float The rotation angle of the ``width`` side in radians, measured counterclockwise from the positive x-axis. use_exact : 0 or 1 Set to ``1`` to use an exact method to calculate the overlap between the rectangle and each pixel. Set to ``0`` to use a subpixel sampling method to calculate the overlap, where each pixel is divided into ``subpixels ** 2`` subpixels and the fraction of subpixels that are within the rectangle is used to estimate the overlap. subpixels : int The number of subpixels to use in each dimension when using the subpixel sampling method (``use_exact=0``). It must be at least 1 in that case. It is ignored when ``use_exact=1``. Each pixel is resampled by this factor in each dimension, so each pixel is divided into ``subpixels ** 2`` subpixels. A subpixel is included only if its center lies strictly inside the rectangle. Subpixel centers lying exactly on the rectangle boundary are excluded (weight 0). Returns ------- result : `~numpy.ndarray` (float) A 2D array of shape (ny, nx) giving the fraction of each pixel's area that overlaps with the rectangle, ranging from 0 to 1. The element at index (j, i) corresponds to the pixel with corners at (xmin + i * dx, ymin + j * dy) and (xmin + (i + 1) * dx, ymin + (j + 1) * dy), where dx and dy are the width of each pixel in the x and y direction, respectively. Raises ------ ValueError If ``use_exact`` is not 0 or 1, or if ``subpixels`` is less than 1 when ``use_exact=0``. """ if use_exact != 0 and use_exact != 1: msg = 'use_exact must be 0 or 1' raise ValueError(msg) if use_exact == 0 and subpixels < 1: msg = 'subpixels must be a strictly positive integer' raise ValueError(msg) cdef unsigned int i, j cdef int i_min, i_max, j_min, j_max cdef double pxmin, pxmax, pymin, pymax cdef double dx, dy cdef double half_width = 0.5 * width cdef double half_height = 0.5 * height cdef double cos_theta = cos(theta) cdef double sin_theta = sin(theta) cdef double pixel_area cdef double bbox_dx, bbox_dy cdef double margin, w_in, w_out, h_in, h_out cdef double pxcen, pycen, xrot, yrot, axrot, ayrot cdef double poly_x[4] cdef double poly_y[4] cdef double buf_a_x[32] cdef double buf_a_y[32] cdef double buf_b_x[32] cdef double buf_b_y[32] cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) cdef double[:, ::1] frac_view = frac dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny # Axis-aligned bounding box of the rotated rectangle, used to # restrict the pixel loops to the bounding-box index range (pixels # outside it have zero overlap). The clamping to [0, nx] and [0, ny] # is done in floating point to avoid integer overflow for rectangles # far outside the grid. bbox_dx = (half_width * fabs(cos_theta) + half_height * fabs(sin_theta)) bbox_dy = (half_width * fabs(sin_theta) + half_height * fabs(cos_theta)) # Interior/exterior fast-path thresholds. Rotation into the # rectangle frame is an isometry, so every point of a pixel lies # within ``margin`` (half the pixel diagonal) of the rotated pixel # center. A pixel is therefore wholly inside the rectangle if its # rotated center is at least ``margin`` inside both half-extents, # and wholly outside if it is at least ``margin`` beyond either # half-extent. Only the boundary band needs the exact polygon clip # or the subpixel sampling. A wholly inside pixel has every subpixel # center inside the rectangle (fraction 1) and a wholly outside # pixel has none (fraction 0), so both modes share the fast path. # A half-extent smaller than ``margin`` gives a negative inner # threshold, which never matches (the rotated center distances are # >= 0), so no pixel is classified as wholly inside a rectangle # smaller than the pixel diagonal. margin = 0.5 * sqrt(dx * dx + dy * dy) w_in = half_width - margin w_out = half_width + margin h_in = half_height - margin h_out = half_height + margin i_min = fmax(0.0, fmin(nx, floor((-bbox_dx - xmin) / dx))) i_max = fmax(0.0, fmin(nx, ceil((bbox_dx - xmin) / dx))) j_min = fmax(0.0, fmin(ny, floor((-bbox_dy - ymin) / dy))) j_max = fmax(0.0, fmin(ny, ceil((bbox_dy - ymin) / dy))) if use_exact == 1: # Build the four CCW vertices of the rotated rectangle (centered # on the origin) via ``rect_vertices``. rect_vertices(half_width, half_height, cos_theta, sin_theta, poly_x, poly_y) pixel_area = dx * dy with nogil: for i in range(i_min, i_max): pxmin = xmin + i * dx pxmax = pxmin + dx pxcen = pxmin + 0.5 * dx for j in range(j_min, j_max): pymin = ymin + j * dy pymax = pymin + dy pycen = pymin + 0.5 * dy # Rotate the pixel center into the rectangle frame. xrot = pxcen * cos_theta + pycen * sin_theta yrot = -pxcen * sin_theta + pycen * cos_theta axrot = fabs(xrot) ayrot = fabs(yrot) if axrot >= w_out or ayrot >= h_out: continue # wholly outside, frac stays 0 if axrot <= w_in and ayrot <= h_in: frac_view[j, i] = 1.0 # wholly inside continue frac_view[j, i] = ( polygon_pixel_overlap(pxmin, pymin, pxmax, pymax, poly_x, poly_y, 4, buf_a_x, buf_a_y, buf_b_x, buf_b_y, 32) / pixel_area) return frac # Subpixel-sampling fallback, restricted to the same bounding-box # index range and sharing the interior/exterior fast path, so only # the boundary band is sampled. with nogil: for i in range(i_min, i_max): pxmin = xmin + i * dx pxmax = pxmin + dx pxcen = pxmin + 0.5 * dx for j in range(j_min, j_max): pymin = ymin + j * dy pymax = pymin + dy pycen = pymin + 0.5 * dy # Rotate the pixel center into the rectangle frame. xrot = pxcen * cos_theta + pycen * sin_theta yrot = -pxcen * sin_theta + pycen * cos_theta axrot = fabs(xrot) ayrot = fabs(yrot) if axrot >= w_out or ayrot >= h_out: continue # wholly outside, frac stays 0 if axrot <= w_in and ayrot <= h_in: frac_view[j, i] = 1.0 # wholly inside continue frac_view[j, i] = rectangle_overlap_single_subpixel( pxmin, pymin, pxmax, pymax, half_width, half_height, cos_theta, sin_theta, subpixels) return frac cdef double rectangle_overlap_single_subpixel(double x0, double y0, double x1, double y1, double half_width, double half_height, double cos_theta, double sin_theta, int subpixels) noexcept nogil: """ Fraction of overlap between a rectangle centered on the origin and a single pixel, using subpixel sampling. Parameters ---------- x0, y0, x1, y1 : double The lower and upper edges of the pixel. half_width, half_height : double Half the width and half the height of the rectangle. cos_theta, sin_theta : double The cosine and sine of the rectangle's rotation angle. subpixels : int The number of subpixels to sample in each dimension. Returns ------- frac : double The fraction (0 to 1) of the pixel's area that overlaps the rectangle. """ cdef unsigned int _i, _j cdef double x, y, x_tr, y_tr cdef double frac = 0.0 cdef double dx = (x1 - x0) / subpixels cdef double 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 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-24be529/photutils/geometry/tests/000077500000000000000000000000001526452531300226405ustar00rootroot00000000000000astropy-photutils-24be529/photutils/geometry/tests/__init__.py000066400000000000000000000000001526452531300247370ustar00rootroot00000000000000astropy-photutils-24be529/photutils/geometry/tests/test_circle_overlap.py000066400000000000000000000055141526452531300272470ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the circle_overlap module. """ import numpy as np 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) @pytest.mark.parametrize('r', [0.3, 0.5, 1.7, 3.3]) def test_circular_overlap_exact_total_area(r): """ The exact-mode total overlap times the pixel area equals the analytic circle area when the circle fits inside the grid. """ grid = circular_overlap_grid(-5.0, 5.0, -5.0, 5.0, 100, 100, r, 1, 1) pixel_area = (10.0 / 100) ** 2 assert_allclose(grid.sum() * pixel_area, np.pi * r ** 2, rtol=1e-12) def test_circular_overlap_exact_matches_high_subpixel(): """ The exact result should agree with a high subpixel approximation to within the subpixel-sampling error. """ args = (-2.0, 2.0, -2.0, 2.0, 20, 20, 1.3) exact = circular_overlap_grid(*args, 1, 1) sub = circular_overlap_grid(*args, 0, 64) assert np.abs(exact - sub).max() < 0.02 def test_circular_overlap_grid_validation(): """ Test that invalid use_exact and subpixels inputs raise errors. """ match = 'use_exact must be 0 or 1' with pytest.raises(ValueError, match=match): circular_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 0.5, 2, 5) match = 'subpixels must be a strictly positive integer' for subpixels in (0, -1): with pytest.raises(ValueError, match=match): circular_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 0.5, 0, subpixels) # subpixels is ignored (not validated) for the exact method g = circular_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 0.5, 1, 0) assert np.isfinite(g).all() @pytest.mark.parametrize('use_exact', use_exacts) def test_circular_overlap_grid_no_readonly_inputs(use_exact): """ Test that the scalar-input overlap grid function takes no array inputs (so read-only input arrays cannot occur) and returns a freshly allocated, writable output array of shape (ny, nx). """ g = circular_overlap_grid(-5.0, 5.0, -5.0, 5.0, 10, 12, 3.0, use_exact, 5) assert g.shape == (12, 10) assert g.flags.writeable astropy-photutils-24be529/photutils/geometry/tests/test_ellipse_overlap.py000066400000000000000000000131521526452531300274400ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the ellipse_overlap module. """ import numpy as np 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) @pytest.mark.parametrize('theta', [0.0, 0.3]) def test_elliptical_overlap_smaller_than_pixel(theta): """ An ellipse smaller than a pixel, centered exactly on a pixel center, must return the ellipse area, not 1.0. """ area = np.pi * 0.4 * 0.3 grid = elliptical_overlap_grid(-1.5, 1.5, -1.5, 1.5, 3, 3, 0.4, 0.3, theta, 1, 1) assert_allclose(grid[1, 1], area, rtol=1e-10) assert_allclose(grid.sum(), area, rtol=1e-10) @pytest.mark.parametrize(('rx', 'ry', 'theta'), [ (3.0, 1.5, 0.7), (2.2, 0.4, -1.2), (4.0, 4.0, 0.0), (0.4, 0.3, 0.5), ]) def test_elliptical_overlap_exact_total_area(rx, ry, theta): """ The exact-mode total overlap times the pixel area equals the analytic ellipse area when the ellipse fits inside the grid, for various axis ratios and rotation angles. """ grid = elliptical_overlap_grid(-5.0, 5.0, -5.0, 5.0, 100, 100, rx, ry, theta, 1, 1) pixel_area = (10.0 / 100) ** 2 assert_allclose(grid.sum() * pixel_area, np.pi * rx * ry, rtol=1e-10) def test_elliptical_overlap_exact_vertex_on_ellipse(): """ A pixel with one corner exactly on the ellipse and the other corners outside can still overlap the ellipse. """ grid = elliptical_overlap_grid(0.0, 3.0, -1.0, 2.0, 1, 1, 1.0, 1.0, 0.0, 1, 1) assert_allclose(grid[0, 0] * 9.0, np.pi / 2.0, rtol=1e-12) @pytest.mark.parametrize(('xmin', 'ymin', 'rx', 'ry', 'theta'), [ (-1.0, -0.5, 1.0, 0.5, 0.0), (-2.0, -0.5, 2.0, 0.5, 0.0), (-0.5, -1.0, 0.5, 0.5, 0.0), (-0.5, -0.5, 0.5, 0.5, np.pi / 4.0), (-1.0, -1.0, 1.0, 1.0, np.pi / 2.0), (-1.0, -1.0, 0.5, 0.5, np.pi / 3.0), (-1.0, -1.0, 0.5, 1.5, np.pi / 2.0), (-3.0, -2.0, 3.0, 2.0, 0.0), (-2.0, -3.0, 3.0, 2.0, np.pi / 2.0), (-0.5, -0.5, 0.5, 0.25, np.pi / 4.0), (-1.5, -1.5, np.sqrt(2.0), 0.5, np.pi / 4.0), (-1.5, -1.5, np.sqrt(2.0), 0.5, 3.0 * np.pi / 4.0), ]) def test_elliptical_overlap_exact_degenerate(xmin, ymin, rx, ry, theta): """ The exact overlap is correct when pixel edges are tangent to the ellipse or pixel corners lie exactly on it. """ nx = round(-2.0 * xmin) + 2 ny = round(-2.0 * ymin) + 2 grid = elliptical_overlap_grid(xmin - 1.0, -xmin + 1.0, ymin - 1.0, -ymin + 1.0, nx, ny, rx, ry, theta, 1, 1) assert_allclose(grid.sum(), np.pi * rx * ry, rtol=1e-10) assert grid.min() >= 0.0 assert grid.max() <= 1.0 + 1e-12 # The result agrees with the subpixel method to within its # sampling error sub = elliptical_overlap_grid(xmin - 1.0, -xmin + 1.0, ymin - 1.0, -ymin + 1.0, nx, ny, rx, ry, theta, 0, 64) assert np.abs(grid - sub).max() < 0.02 def test_elliptical_overlap_exact_matches_high_subpixel(): """ The exact result should agree with a high subpixel approximation to within the subpixel-sampling error. """ args = (-2.0, 2.0, -2.0, 2.0, 20, 20, 1.5, 0.8, 0.4) exact = elliptical_overlap_grid(*args, 1, 1) sub = elliptical_overlap_grid(*args, 0, 64) assert np.abs(exact - sub).max() < 0.02 def test_elliptical_overlap_grid_validation(): """ Test that invalid use_exact and subpixels inputs raise errors. """ match = 'use_exact must be 0 or 1' with pytest.raises(ValueError, match=match): elliptical_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 0.5, 0.3, 0.1, 2, 5) match = 'subpixels must be a strictly positive integer' for subpixels in (0, -1): with pytest.raises(ValueError, match=match): elliptical_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 0.5, 0.3, 0.1, 0, subpixels) # subpixels is ignored (not validated) for the exact method g = elliptical_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 0.5, 0.3, 0.1, 1, 0) assert np.isfinite(g).all() @pytest.mark.parametrize('use_exact', use_exacts) def test_elliptical_overlap_grid_no_readonly_inputs(use_exact): """ Test that the scalar-input overlap grid function takes no array inputs (so read-only input arrays cannot occur) and returns a freshly allocated, writable output array of shape (ny, nx). """ g = elliptical_overlap_grid(-5.0, 5.0, -5.0, 5.0, 10, 12, 4.0, 2.0, 0.5, use_exact, 5) assert g.shape == (12, 10) assert g.flags.writeable astropy-photutils-24be529/photutils/geometry/tests/test_polygon_overlap.py000066400000000000000000000355471526452531300275060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _polygon_overlap module. """ import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from photutils.geometry._polygon_overlap import polygon_overlap_grid def test_polygon_overlap_clockwise_input(): """ Clockwise-ordered vertices should give the same result as counterclockwise ones. """ vx_ccw = np.array([-1.0, 1.0, 1.0, -1.0]) vy_ccw = np.array([-1.0, -1.0, 1.0, 1.0]) vx_cw = vx_ccw[::-1].copy() vy_cw = vy_ccw[::-1].copy() grid_ccw = polygon_overlap_grid(-2.5, 2.5, -2.5, 2.5, 5, 5, vx_ccw, vy_ccw, 1, 1) grid_cw = polygon_overlap_grid(-2.5, 2.5, -2.5, 2.5, 5, 5, vx_cw, vy_cw, 1, 1) assert_allclose(grid_cw, grid_ccw) @pytest.mark.parametrize('use_exact', [1, 0]) def test_polygon_overlap_readonly_vertices(use_exact): """ Regression test that read-only (nonwritable) vertex arrays are accepted and give results identical to writable arrays. The vertex arrays are buffered into ``const`` typed memoryviews so that read-only arrays do not raise a ``ValueError``. """ vx = np.array([-4.0, 6.0, 3.0, -5.0]) vy = np.array([-3.0, -2.0, 5.0, 4.0]) expected = polygon_overlap_grid(-10.0, 10.0, -10.0, 10.0, 40, 40, vx, vy, use_exact, 5) vx_ro = vx.copy() vy_ro = vy.copy() vx_ro.setflags(write=False) vy_ro.setflags(write=False) result = polygon_overlap_grid(-10.0, 10.0, -10.0, 10.0, 40, 40, vx_ro, vy_ro, use_exact, 5) assert_allclose(result, expected, rtol=1e-12) @pytest.mark.parametrize(('leg', 'dx', 'dy'), [ (3.0, 0.0, 0.0), # base case (1.0, 0.0, 0.0), # small triangle (6.0, 0.0, 0.0), # large triangle (3.0, 0.5, 0.0), # shifted half-pixel in x (3.0, 0.0, 0.5), # shifted half-pixel in y (3.0, 0.25, 0.25), # shifted diagonally (2.0, 1.0, -1.0), # different size with offset ]) def test_polygon_overlap_triangle_area(leg, dx, dy): """ The total overlap of a right triangle with given leg length equals its analytic area, for various sizes and pixel offsets. """ vx = np.array([0.0, leg, 0.0]) + dx vy = np.array([0.0, 0.0, leg]) + dy margin = 0.5 xmin = vx.min() - margin xmax = vx.max() + margin ymin = vy.min() - margin ymax = vy.max() + margin nx = max(4, int(leg * 4)) pixel_area = ((xmax - xmin) / nx) * ((ymax - ymin) / nx) grid = polygon_overlap_grid( xmin, xmax, ymin, ymax, nx, nx, vx, vy, 1, 1, ) assert_allclose(grid.sum() * pixel_area, 0.5 * leg * leg) @pytest.mark.parametrize(('half_side', 'subpix', 'atol'), [ (1.0, 16, 0.05), # base case, 2x2 square (2.0, 16, 0.5), # larger square, 4x4 area (0.5, 4, 0.3), # small square (1.0, 1, 2.0), # subpixel=1 (center-of-pixel, coarse by design) (1.0, 32, 0.02), # high subpixel sampling ]) def test_polygon_overlap_subpixel_mode(half_side, subpix, atol): """ Subpixel mode returns values in [0, 1] and the total weighted area matches the analytic area for various square sizes and sampling sizes. """ s = half_side vx = np.array([-s, s, s, -s]) vy = np.array([-s, -s, s, s]) lim = s + 1.0 nx = 5 pixel_area = (2.0 * lim / nx) ** 2 grid = polygon_overlap_grid(-lim, lim, -lim, lim, nx, nx, vx, vy, 0, subpix) assert grid.min() >= 0.0 assert grid.max() <= 1.0 assert_allclose(grid.sum() * pixel_area, (2.0 * s) ** 2, atol=atol) @pytest.mark.parametrize('subpix', [1, 3, 5]) def test_polygon_overlap_subpixel_convex_fast_path(subpix): """ Test that the convex interior/exterior fast path of the subpixel mode returns results identical to sampling every pixel. The same hexagon with a duplicated vertex is geometrically identical but is reported as nonconvex (the zero-length edge fails ``convex_edge_normals``), which disables the fast path and forces the full scanline sampling for every pixel. """ angles = np.pi / 6.0 + np.arange(6) * np.pi / 3.0 vx = np.ascontiguousarray(4.0 * np.cos(angles)) vy = np.ascontiguousarray(4.0 * np.sin(angles)) vx_dup = np.append(vx, vx[0]) vy_dup = np.append(vy, vy[0]) fast = polygon_overlap_grid(-5.0, 5.0, -5.0, 5.0, 40, 40, vx, vy, 0, subpix) slow = polygon_overlap_grid(-5.0, 5.0, -5.0, 5.0, 40, 40, vx_dup, vy_dup, 0, subpix) assert_array_equal(fast, slow) # Sanity check that interior pixels exist and are exactly 1 assert fast.max() == 1.0 def test_polygon_overlap_no_intersection(): """ A polygon that does not intersect the grid yields all zeros. """ vx = np.array([10.0, 12.0, 11.0]) vy = np.array([10.0, 10.0, 12.0]) grid = polygon_overlap_grid(-2.0, 2.0, -2.0, 2.0, 4, 4, vx, vy, 1, 1) assert_allclose(grid, 0.0) def test_polygon_overlap_nonconvex_l_shape(): """ The exact-mode kernel handles nonconvex (L-shaped) polygons. The L-shape used here has area 12 (4x4 square minus 2x2 corner). """ vx = np.array([0.0, 4.0, 4.0, 2.0, 2.0, 0.0]) vy = np.array([0.0, 0.0, 2.0, 2.0, 4.0, 4.0]) grid = polygon_overlap_grid(-1.0, 5.0, -1.0, 5.0, 60, 60, vx, vy, 1, 1) pixel_area = (6.0 / 60) ** 2 assert_allclose(grid.sum() * pixel_area, 12.0, atol=1e-12) def test_polygon_overlap_nonconvex_star(): """ The exact-mode kernel handles a 5-pointed star (nonconvex, non-self-intersecting). The total area should match the shoelace area of the polygon. """ n_points = 5 r_outer, r_inner = 5.0, 2.0 angles = (np.pi / 2 + np.arange(2 * n_points) * np.pi / n_points) radii = np.where(np.arange(2 * n_points) % 2 == 0, r_outer, r_inner) vx = np.ascontiguousarray(radii * np.cos(angles)) vy = np.ascontiguousarray(radii * np.sin(angles)) shoelace = abs(0.5 * np.sum(vx * np.roll(vy, -1) - np.roll(vx, -1) * vy)) grid = polygon_overlap_grid(-6.0, 6.0, -6.0, 6.0, 120, 120, vx, vy, 1, 1) pixel_area = (12.0 / 120) ** 2 assert_allclose(grid.sum() * pixel_area, shoelace, atol=1e-10) @pytest.mark.parametrize(('width', 'height'), [ (2.0, 2.0), # square (4.0, 1.0), # wide rectangle (1.0, 4.0), # tall rectangle (0.5, 0.5), # subpixel rectangle (3.0, 2.5), # noninteger dimensions ]) def test_polygon_overlap_rectangle_area(width, height): """ The total overlap of an axis-aligned rectangle equals width * height. """ hw, hh = width / 2.0, height / 2.0 vx = np.array([-hw, hw, hw, -hw]) vy = np.array([-hh, -hh, hh, hh]) margin = 0.5 xmin, xmax = -hw - margin, hw + margin ymin, ymax = -hh - margin, hh + margin nx = max(4, int((width + 2 * margin) * 10)) ny = max(4, int((height + 2 * margin) * 10)) pixel_area = ((xmax - xmin) / nx) * ((ymax - ymin) / ny) grid = polygon_overlap_grid( xmin, xmax, ymin, ymax, nx, ny, vx, vy, 1, 1, ) assert_allclose(grid.sum() * pixel_area, width * height) @pytest.mark.parametrize('radius', [1.0, 2.0, 3.5, 0.8]) def test_polygon_overlap_hexagon_area(radius): """ The total overlap of a regular hexagon equals its analytic area (3*sqrt(3)/2 * radius^2). """ angles = np.pi / 6.0 + np.arange(6) * np.pi / 3.0 vx = np.ascontiguousarray(radius * np.cos(angles)) vy = np.ascontiguousarray(radius * np.sin(angles)) expected_area = 1.5 * np.sqrt(3.0) * radius ** 2 margin = 0.5 n = max(60, int(2.0 * radius * 30)) grid = polygon_overlap_grid( -radius - margin, radius + margin, -radius - margin, radius + margin, n, n, vx, vy, 1, 1, ) pixel_area = ((2.0 * (radius + margin)) / n) ** 2 assert_allclose(grid.sum() * pixel_area, expected_area, atol=1e-10) def test_polygon_overlap_validation(): """ Input validation errors are raised for malformed polygons. """ vx = np.array([0.0, 1.0]) vy = np.array([0.0, 0.0]) match = 'at least 3 vertices' with pytest.raises(ValueError, match=match): polygon_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, vx, vy, 1, 1) vx = np.array([0.0, 1.0, 0.0]) vy = np.array([0.0, 0.0]) match = 'same length' with pytest.raises(ValueError, match=match): polygon_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, vx, vy, 1, 1) vx = np.array([0.0, 1.0, 0.0]) vy = np.array([0.0, 0.0, 1.0]) match = 'use_exact must be 0 or 1' with pytest.raises(ValueError, match=match): polygon_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, vx, vy, 2, 1) match = 'subpixels must be a strictly positive integer' for subpixels in (0, -1): with pytest.raises(ValueError, match=match): polygon_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, vx, vy, 0, subpixels) def test_polygon_overlap_vertices_input_types(): """ Test that integer arrays and Python lists are accepted for the vertex coordinates and give the same result as float64 arrays, and that non-1D vertex arrays raise an error. """ vx = np.array([0.0, 4.0, 0.0]) vy = np.array([0.0, 0.0, 4.0]) expected = polygon_overlap_grid(-1.0, 5.0, -1.0, 5.0, 12, 12, vx, vy, 1, 1) result = polygon_overlap_grid(-1.0, 5.0, -1.0, 5.0, 12, 12, np.array([0, 4, 0]), np.array([0, 0, 4]), 1, 1) assert_allclose(result, expected) result = polygon_overlap_grid(-1.0, 5.0, -1.0, 5.0, 12, 12, [0.0, 4.0, 0.0], [0.0, 0.0, 4.0], 1, 1) assert_allclose(result, expected) match = 'must be 1D arrays' with pytest.raises(ValueError, match=match): polygon_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, np.zeros((3, 2)), np.zeros((3, 2)), 1, 1) def test_polygon_overlap_many_vertices(): """ There is no limit on the number of polygon vertices. A 10000-vertex polygon approximating a circle should have the analytic circle area. """ n_verts = 10000 radius = 3.0 angles = np.linspace(0.0, 2 * np.pi, n_verts, endpoint=False) vx = np.ascontiguousarray(radius * np.cos(angles)) vy = np.ascontiguousarray(radius * np.sin(angles)) n = 70 grid = polygon_overlap_grid(-3.5, 3.5, -3.5, 3.5, n, n, vx, vy, 1, 1) pixel_area = (7.0 / n) ** 2 assert_allclose(grid.sum() * pixel_area, np.pi * radius**2, rtol=1e-6) def test_polygon_overlap_subpixel_on_horizontal_boundary(): """ Test that subpixel centers landing exactly on a horizontal polygon edge are excluded (classified as outside). A wide rectangle spanning y in [-1, 1] is sampled by a single pixel with ``subpixels=3``, so the subpixel-row centers fall exactly at y = -1, 0, +1. The top and bottom rows lie exactly on the rectangle's horizontal edges (with all column centers strictly inside in x), so only the middle row (3 of 9 subpixels) is counted, giving 1/3. Before horizontal-edge handling, the bottom edge (lower y, region above) was incorrectly counted, which would give 2/3 instead. """ vx = np.array([-2.0, 2.0, 2.0, -2.0]) vy = np.array([-1.0, -1.0, 1.0, 1.0]) grid = polygon_overlap_grid(-1.5, 1.5, -1.5, 1.5, 1, 1, vx, vy, 0, 3) assert_allclose(grid[0, 0], 1.0 / 3.0) def test_polygon_overlap_subpixel_on_vertical_boundary(): """ Test that subpixel centers landing exactly on a vertical polygon edge are excluded (classified as outside). A tall rectangle spanning x in [-1, 1] is sampled by a single pixel with ``subpixels=3``, so the subpixel-column centers fall exactly at x = -1, 0, +1. The left and right columns lie exactly on the rectangle's vertical edges (with all row centers strictly inside in y), so only the middle column (3 of 9 subpixels) is counted, giving 1/3. """ vx = np.array([-1.0, 1.0, 1.0, -1.0]) vy = np.array([-2.0, -2.0, 2.0, 2.0]) grid = polygon_overlap_grid(-1.5, 1.5, -1.5, 1.5, 1, 1, vx, vy, 0, 3) assert_allclose(grid[0, 0], 1.0 / 3.0) def test_polygon_overlap_subpixel_on_all_boundaries(): """ Test that subpixel centers on horizontal edges, vertical edges, and at corners are all excluded. A unit square spanning [-1, 1] in both axes is sampled by a single pixel with ``subpixels=3``, so the 9 subpixel centers fall at every combination of (-1, 0, +1). Of these, only the central center (0, 0) is strictly interior. The four edge-midpoint centers (two horizontal, two vertical) and the four corner centers all lie on the boundary and are excluded, giving 1/9. """ vx = np.array([-1.0, 1.0, 1.0, -1.0]) vy = np.array([-1.0, -1.0, 1.0, 1.0]) grid = polygon_overlap_grid(-1.5, 1.5, -1.5, 1.5, 1, 1, vx, vy, 0, 3) assert_allclose(grid[0, 0], 1.0 / 9.0) def test_polygon_overlap_subpixel_on_reflex_vertex(): """ Test that a subpixel center landing exactly on a vertex that is a local maximum in y, with the polygon interior above it, is excluded. Neither edge at such a vertex straddles the sample row, so the vertex produces no crossing. The notch vertex at (1, 1) is sampled by a single pixel with ``subpixels=3``, so the 9 subpixel centers fall at every combination of (0.5, 1, 1.5). The three centers in the bottom row are below the notch edges and the vertex sample is on the boundary, leaving 5 of 9. """ vx = np.array([0.0, 1.0, 2.0, 2.0, 0.0]) vy = np.array([0.0, 1.0, 0.0, 3.0, 3.0]) for sign in (1, -1): grid = polygon_overlap_grid(0.25, 1.75, 0.25, 1.75, 1, 1, vx[::sign], vy[::sign], 0, 3) assert_allclose(grid[0, 0], 5.0 / 9.0) def test_polygon_overlap_subpixel_on_vertex_matches_center(): """ Test that a subpixel center landing exactly on a vertex whose neighbors lie on opposite sides of the sample row is excluded. The subpixel fraction of a single pixel must equal the mean of the ``center`` method evaluated on the same sample points. """ rng = np.random.default_rng(0) for _ in range(500): # A triangle with a vertex on the middle sample at (1, 1), one # vertex below the sample row, and one vertex above it vx = np.array([1.0, rng.uniform(-3, 0), rng.uniform(-3, 0)]) vy = np.array([1.0, rng.uniform(-3, 0.9), rng.uniform(1.1, 4)]) for sign in (1, -1): args = (vx[::sign], vy[::sign], 0) subpix = polygon_overlap_grid(0.25, 1.75, 0.25, 1.75, 1, 1, *args, 3) center = polygon_overlap_grid(0.25, 1.75, 0.25, 1.75, 3, 3, *args, 1) assert center[1, 1] == 0.0 assert_allclose(subpix[0, 0], center.mean()) astropy-photutils-24be529/photutils/geometry/tests/test_rectangle_overlap.py000066400000000000000000000120251526452531300277450ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the rectangle_overlap module. """ import math import numpy as np 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) def test_axis_aligned_rectangle_exact(): """ Exact mode reproduces the analytic overlap for an axis-aligned rectangle that exactly fills an integer number of pixels. """ grid = rectangular_overlap_grid(-2.5, 2.5, -2.5, 2.5, 5, 5, 2.0, 2.0, 0.0, 1, 1) assert_allclose(grid.sum(), 4.0) expected = np.zeros((5, 5)) # Center pixel fully inside. expected[2, 2] = 1.0 # Edge pixels: half overlap. expected[1, 2] = expected[3, 2] = 0.5 expected[2, 1] = expected[2, 3] = 0.5 # Corner pixels: quarter overlap. expected[1, 1] = expected[1, 3] = 0.25 expected[3, 1] = expected[3, 3] = 0.25 assert_allclose(grid, expected) def test_rectangle_exact_total_area_preserved(): """ For any rotation angle, the sum of the exact overlap fractions times the pixel area equals the analytic rectangle area, provided the rectangle fits inside the grid. """ width, height = 3.7, 1.4 pixel_area = (10.0 / 25) ** 2 for theta in (0.0, 0.1, math.pi / 6, math.pi / 4, math.pi / 3, 1.7): grid = rectangular_overlap_grid(-5.0, 5.0, -5.0, 5.0, 25, 25, width, height, theta, 1, 1) assert_allclose(grid.sum() * pixel_area, width * height, rtol=1e-12, atol=1e-12) def test_rectangle_exact_matches_high_subpixel(): """ The exact result should agree with a high subpixel approximation to within the subpixel-sampling error. """ args = (-3.5, 3.5, -3.5, 3.5, 35, 35, 2.0, 1.0, math.pi / 5) exact = rectangular_overlap_grid(*args, 1, 1) sub = rectangular_overlap_grid(*args, 0, 64) assert np.abs(exact - sub).max() < 0.02 @pytest.mark.parametrize('use_exact', [0, 1]) def test_rectangle_full_pixel_is_one(use_exact): """ A pixel completely inside the rectangle must have overlap = 1. """ grid = rectangular_overlap_grid(-2.0, 2.0, -2.0, 2.0, 4, 4, 10.0, 10.0, 0.3, use_exact, 4) assert_allclose(grid, 1.0) @pytest.mark.parametrize('theta', [0.0, 0.3]) def test_rectangle_exact_smaller_than_pixel(theta): """ A rectangle smaller than a pixel, centered exactly on a pixel center, must return the rectangle area, not 1.0. """ grid = rectangular_overlap_grid(-1.5, 1.5, -1.5, 1.5, 3, 3, 0.6, 0.8, theta, 1, 1) assert_allclose(grid[1, 1], 0.48, rtol=1e-12) assert_allclose(grid.sum(), 0.48, rtol=1e-12) @pytest.mark.parametrize('use_exact', [0, 1]) def test_rectangle_no_overlap(use_exact): """ A rectangle entirely outside the grid yields all zeros. """ grid = rectangular_overlap_grid(10.0, 12.0, 10.0, 12.0, 4, 4, 1.0, 1.0, 0.0, use_exact, 4) assert_allclose(grid, 0.0) def test_rectangular_overlap_grid_validation(): """ Test that invalid use_exact and subpixels inputs raise errors. """ match = 'use_exact must be 0 or 1' with pytest.raises(ValueError, match=match): rectangular_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 1.0, 0.5, 0.1, 2, 5) match = 'subpixels must be a strictly positive integer' for subpixels in (0, -1): with pytest.raises(ValueError, match=match): rectangular_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 1.0, 0.5, 0.1, 0, subpixels) # subpixels is ignored (not validated) for the exact method g = rectangular_overlap_grid(-1.0, 1.0, -1.0, 1.0, 4, 4, 1.0, 0.5, 0.1, 1, 0) assert np.isfinite(g).all() @pytest.mark.parametrize('use_exact', [0, 1]) def test_rectangular_overlap_grid_no_readonly_inputs(use_exact): """ Test that the scalar-input overlap grid function takes no array inputs (so read-only input arrays cannot occur) and returns a freshly allocated, writable output array of shape (ny, nx). """ g = rectangular_overlap_grid(-5.0, 5.0, -5.0, 5.0, 10, 12, 4.0, 2.0, 0.5, use_exact, 5) assert g.shape == (12, 10) assert g.flags.writeable astropy-photutils-24be529/photutils/geometry/tests/test_thread_safety.py000066400000000000000000000137451526452531300271050ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Test that the Cython overlap functions are free-threading compatible and return identical results when run concurrently from multiple threads. This catches data races and shared-state issues introduced by ``with nogil:`` sections in the functions, and verifies that each compiled module opts into free-threading (``freethreading_compatible=True``). """ import importlib import sys import sysconfig from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.geometry import (circular_overlap_grid, elliptical_overlap_grid, rectangular_overlap_grid) from photutils.geometry._polygon_overlap import polygon_overlap_grid N_THREADS = 8 N_CALLS_PER_THREAD = 4 # The compiled geometry modules that carry the # ``# cython: freethreading_compatible=True`` directive. GEOMETRY_MODULES = ('core', 'circle_overlap', 'ellipse_overlap', 'rectangle_overlap', '_polygon_overlap') GIL_DISABLED = bool(sysconfig.get_config_var('Py_GIL_DISABLED')) @pytest.mark.parametrize('modname', GEOMETRY_MODULES) def test_geometry_module_importable(modname): """ Test that each compiled geometry module imports cleanly. On a free-threaded build, importing a module that was not compiled with ``freethreading_compatible=True`` emits a ``RuntimeWarning`` and re-enables the GIL. This import check is the entry point for the free-threading verification below. """ assert importlib.import_module(f'photutils.geometry.{modname}') is not None @pytest.mark.skipif(not GIL_DISABLED, reason='requires a free-threaded (GIL-disabled) build') def test_geometry_modules_do_not_reenable_gil(): """ Test that importing the geometry Cython modules does not re-enable the GIL on a free-threaded build. Each module is compiled with the ``freethreading_compatible=True`` directive, which marks it as free-threading compatible (``Py_MOD_GIL_NOT_USED``). A module lacking that directive would force the runtime to re-enable the GIL on import, which this test detects. """ for modname in GEOMETRY_MODULES: importlib.import_module(f'photutils.geometry.{modname}') assert sys._is_gil_enabled() is False def _run_concurrent(fn, *, n_threads=N_THREADS, n_calls=N_CALLS_PER_THREAD): expected = fn() with ThreadPoolExecutor(max_workers=n_threads) as ex: futures = [ex.submit(fn) for _ in range(n_threads * n_calls)] for fut in futures: np.testing.assert_array_equal(fut.result(), expected) @pytest.mark.parametrize('use_exact', [1, 0]) def test_circular_overlap_grid_threadsafe(use_exact): def fn(): return circular_overlap_grid(-15.0, 15.0, -15.0, 15.0, 200, 200, 8.0, use_exact, 5) _run_concurrent(fn) @pytest.mark.parametrize('use_exact', [1, 0]) def test_elliptical_overlap_grid_threadsafe(use_exact): def fn(): return elliptical_overlap_grid(-15.0, 15.0, -15.0, 15.0, 200, 200, 8.0, 5.0, 0.7, use_exact, 5) _run_concurrent(fn) @pytest.mark.parametrize('use_exact', [1, 0]) def test_rectangular_overlap_grid_threadsafe(use_exact): def fn(): return rectangular_overlap_grid(-15.0, 15.0, -15.0, 15.0, 200, 200, 12.0, 7.0, 0.5, use_exact, 5) _run_concurrent(fn) @pytest.mark.parametrize('use_exact', [1, 0]) def test_polygon_overlap_grid_threadsafe(use_exact): n_vertices = 32 angles = np.linspace(0.0, 2.0 * np.pi, n_vertices, endpoint=False) radii = 8.0 + 2.0 * np.sin(5.0 * angles) vx = np.ascontiguousarray(radii * np.cos(angles)) vy = np.ascontiguousarray(radii * np.sin(angles)) def fn(): return polygon_overlap_grid(-15.0, 15.0, -15.0, 15.0, 200, 200, vx, vy, use_exact, 8) _run_concurrent(fn) def test_mixed_functions_concurrent(): """ Run all geometry function types concurrently from many threads. This is a more aggressive smoke test that mixes different functions so that if any of them shares mutable state (e.g., a module-level static buffer), interference between calls would surface. """ n_vertices = 16 angles = np.linspace(0.0, 2.0 * np.pi, n_vertices, endpoint=False) vx = np.ascontiguousarray(7.0 * np.cos(angles)) vy = np.ascontiguousarray(5.0 * np.sin(angles)) expected_circ = circular_overlap_grid(-10.0, 10.0, -10.0, 10.0, 150, 150, 6.0, 1, 5) expected_ell = elliptical_overlap_grid(-10.0, 10.0, -10.0, 10.0, 150, 150, 7.0, 4.0, 0.3, 1, 5) expected_rect = rectangular_overlap_grid(-10.0, 10.0, -10.0, 10.0, 150, 150, 9.0, 5.0, 0.4, 1, 5) expected_poly = polygon_overlap_grid(-10.0, 10.0, -10.0, 10.0, 150, 150, vx, vy, 1, 5) def task(which): if which == 0: return 'c', circular_overlap_grid( -10.0, 10.0, -10.0, 10.0, 150, 150, 6.0, 1, 5) if which == 1: return 'e', elliptical_overlap_grid( -10.0, 10.0, -10.0, 10.0, 150, 150, 7.0, 4.0, 0.3, 1, 5) if which == 2: return 'r', rectangular_overlap_grid( -10.0, 10.0, -10.0, 10.0, 150, 150, 9.0, 5.0, 0.4, 1, 5) return 'p', polygon_overlap_grid( -10.0, 10.0, -10.0, 10.0, 150, 150, vx, vy, 1, 5) expected_map = {'c': expected_circ, 'e': expected_ell, 'r': expected_rect, 'p': expected_poly} with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futures = [ex.submit(task, i % 4) for i in range(N_THREADS * 8)] for future in futures: tag, result = future.result() assert_array_equal(result, expected_map[tag]) astropy-photutils-24be529/photutils/isophote/000077500000000000000000000000001526452531300214755ustar00rootroot00000000000000astropy-photutils-24be529/photutils/isophote/__init__.py000066400000000000000000000007631526452531300236140ustar00rootroot00000000000000# 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-24be529/photutils/isophote/_ellipse_model.pyx000066400000000000000000000124611526452531300252170ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Faster evaluation of ellipses from model.py. """ import numpy as np cdef extern from "math.h" nogil: double cos(double x) double sin(double x) double sqrt(double x) DTYPE = np.float64 def build_ellipse_model_c( unsigned int n_rows, unsigned int n_cols, const double[:] finely_spaced_sma, const double[:] intens_array, const double[:] eps_array, const double[:] pa_array, const double[:] x0_array, const double[:] y0_array, const double[:] a3_array = None, const double[:] b3_array = None, const double[:] a4_array = None, const double[:] b4_array = None, double phi_min = 0., double phi_max = 2.0*np.pi, ): cdef Py_ssize_t len_sma len_sma = len(finely_spaced_sma) # Do not place the memoryview arguments in a tuple. Cython infers # a ctuple type for a tuple of memoryviews and rejects it as of # Cython 3.3, so each array is checked explicitly. if (len(intens_array) != len_sma or len(eps_array) != len_sma or len(pa_array) != len_sma or len(x0_array) != len_sma or len(y0_array) != len_sma): msg = f"All input arrays must be same length={len_sma}" raise ValueError(msg) harmonics_is_none = [a3_array is None, b3_array is None, a4_array is None, b4_array is None] cdef double a3, b3, a4, b4 cdef bint do_harmonics if all(harmonics_is_none): a3 = 0 b3 = 0 a4 = 0 b4 = 0 do_harmonics = False else: if any(harmonics_is_none): msg = "Must supply all harmonic arrays if any is not None" raise ValueError(msg) if (len(a3_array) != len_sma or len(b3_array) != len_sma or len(a4_array) != len_sma or len(b4_array) != len_sma): msg = f"All input arrays must be same length={len_sma}" raise ValueError(msg) do_harmonics = True cdef double phi, fx, fy, x, y cdef double one_m_fx, one_m_fy, weight_pix cdef double r, sma, q, pa, x0, y0, intens0, intens cdef int i, j, i_max, j_max cdef Py_ssize_t index cdef bint i_ge_zero, i_p1_le_max # Define output array cdef double[:, ::1] result = np.zeros([n_rows, n_cols], dtype=DTYPE) cdef double[:, ::1] 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): 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: 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 = intens0 if do_harmonics: intens += (a3 * sin(3.0 * phi) + b3 * cos(3.0 * phi) + a4 * sin(4.0 * phi) + b4 * cos(4.0 * phi)) 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 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-24be529/photutils/isophote/ellipse.py000066400000000000000000001046551526452531300235170ustar00rootroot00000000000000# 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 nonflagged 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 nonelliptical 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 nonsignificant 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 noniterative 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 noniterative 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. Noniterative 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 semimajor 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 noniterative # 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 the next sma. If it exceeded the user-defined # minimum, or is 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 noniterative 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. Noniterative 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 noniterative 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 the isophote is bad. If so, fix its geometry # to be like the geometry of the index-th isophote # in the 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-24be529/photutils/isophote/fitter.py000066400000000000000000000416751526452531300233610ustar00rootroot00000000000000# 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 nonsignificant 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 noniterative 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 2D 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, so 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 nonmasked 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, so return the best-fit sample # instead of the current one. 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) # The parameter correctors divide by the sample's local # gradient. If the gradient is zero or nonfinite (e.g., # from a degenerate sample where no gradient could be # measured), no correction can be computed and iterations # cannot proceed. proceed = (sample.gradient != 0.0 and np.isfinite(sample.gradient)) if proceed: # Pick appropriate corrector code. corrector = _CORRECTORS[largest_harmonic_index] # Generate a 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 noniterative 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)) # Make the 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 _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-24be529/photutils/isophote/geometry.py000066400000000000000000000503211526452531300237030ustar00rootroot00000000000000# 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 semimajor 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 self._reset_sma_dependent_attributes() def _reset_sma_dependent_attributes(self): """ Reset attributes derived from the current semimajor axis length. """ # 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 for attr in ('sector_angular_width', 'initial_polar_angle', 'initial_polar_radius'): if hasattr(self, attr): delattr(self, attr) # 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 an 11x11 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 11x11 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-24be529/photutils/isophote/harmonics.py000066400000000000000000000102371526452531300240350ustar00rootroot00000000000000# 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-24be529/photutils/isophote/integrator.py000066400000000000000000000266461526452531300242430ustar00rootroot00000000000000# 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 containing the angle values along the elliptical path. radii : list Output list containing the radius values along the elliptical path. intensities : list Output list containing 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. n_pixels = 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, n_pixels = self.accumulate( pix_value, accumulator) # If 6 or less pixels were sampled, get the bilinear # interpolated value instead. if n_pixels 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 n_pixels > 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-24be529/photutils/isophote/isophote.py000066400000000000000000001007171526452531300237070ustar00rootroot00000000000000# 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 from photutils.utils._parameters import validate_table_columns __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 prespecified 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 if sample.actual_points == 0: # No valid sampled points (e.g., a trial geometry that # fell entirely outside the image). Operating on the # empty sample arrays would emit numpy warnings (e.g., # "Mean of empty slice"), so set nonmeaningful values # directly and skip the harmonic computations. Such a # degenerate isophote is transient. It is either # discarded or replaced by `_fix_last_isophote`. self.rms = np.nan self.int_err = np.nan self.pix_stddev = np.nan 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 self.tflux_e = self.tflux_c = 0.0 self.npix_e = self.npix_c = 0 self.x0_err = self.y0_err = self.pa_err = self.ellip_err = 0.0 self.a3 = self.b3 = self.a3_err = self.b3_err = None self.a4 = self.b4 = self.a4_err = self.b4_err = None return 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. """ properties = {} for an_item in isophote_list.__class__.__dict__: p_type = isophote_list.__class__.__dict__[an_item] # Exclude the sample property if isinstance(p_type, property) and 'sample' not in an_item: properties[str(an_item)] = str(an_item) return properties # The "main" isophote_list properties included in to_table() by default. _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') # Isophote_list property names renamed for their to_table() column names. _RENAME_MAP = { 'int_err': 'intens_err', 'eps': 'ellipticity', 'ellip_err': 'ellipticity_err', } def _rename_properties(names): """ Map isophote_list property names to their to_table() column names. Parameters ---------- names : iterable of str The isophote_list property names. Returns ------- properties : dict A dictionary mapping each property name to its (possibly renamed) table column name. """ return {name: _RENAME_MAP.get(name, name) for name in names} 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 : 'main', 'all', or 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. 'all' and 'main' must be passed by themselves and not in a list containing other column names. The default is 'main'. Returns ------- result : `~astropy.table.QTable` An astropy QTable with the selected or all isophote parameters. Raises ------ ValueError If any name in ``columns`` is not a valid (or deprecated) column name. """ _deprecation_map = _DEPRECATED_ATTRIBUTES.copy() all_names = _get_properties(isophote_list).keys() allowed_columns = {*all_names, 'main', 'all'} table_columns = validate_table_columns( columns, allowed_columns, deprecated_names=_deprecation_map) # 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 if table_columns == ['all']: properties = _rename_properties(all_names) elif table_columns == ['main']: properties = _rename_properties(_MAIN_PROPERTIES) else: # Use the canonical (nondeprecated) column name so that # assigning into ``isotable`` does not trigger a second, # redundant deprecation warning (the deprecated attribute access # below already warns once). properties = {name: _deprecation_map.get(name, name) for name in table_columns} 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-24be529/photutils/isophote/model.py000066400000000000000000000111061526452531300231460ustar00rootroot00000000000000# 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.isophote._ellipse_model import build_ellipse_model_c from photutils.utils._deprecation import deprecated_positional_kwargs __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``) to the result. See `~photutils.isophote.Isophote` for details. sma_interval : optional, float The interval between node values of the semimajor 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-24be529/photutils/isophote/sample.py000066400000000000000000000441331526452531300233350ustar00rootroot00000000000000# 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 self.geometry._reset_sma_dependent_attributes() 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 2D 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): 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() # If the elliptical path falls entirely outside the image (e.g., # a trial geometry that diverged during the fit), no points are # sampled. Short-circuit here to avoid emitting numpy warnings # (e.g., "Mean of empty slice") from operating on empty arrays. # The fitter detects this condition separately via its own # zero-length check and marks the isophote as invalid. if len(s[2]) == 0: self.mean = np.nan self.gradient = np.nan self.gradient_err = np.nan self.gradient_rel_err = None return 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 # A meaningful gradient must also be negative (the isophote # intensity decreases outward). A gradient of exactly zero can # occur with the nearest-neighbor integrator when the samples at # both radii extract identical pixel sets, and it would cause # divisions by zero in the fitter's parameter correctors. if (gradient >= (previous_gradient / 3.0) # gradient is negative! or gradient >= 0.0): 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) or gradient >= 0.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() # If the gradient sample (taken at a slightly larger semimajor # axis) falls entirely outside the image, no points are sampled. # Return a nonmeaningful gradient without operating on empty # arrays (which would emit numpy warnings). NaN (rather than # None) is returned to match the previous numeric behavior so # that downstream arithmetic continues to work. if len(sg[2]) == 0: return np.nan, np.nan 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-24be529/photutils/isophote/tests/000077500000000000000000000000001526452531300226375ustar00rootroot00000000000000astropy-photutils-24be529/photutils/isophote/tests/__init__.py000066400000000000000000000000001526452531300247360ustar00rootroot00000000000000astropy-photutils-24be529/photutils/isophote/tests/data/000077500000000000000000000000001526452531300235505ustar00rootroot00000000000000astropy-photutils-24be529/photutils/isophote/tests/data/M51_table.fits000066400000000000000000000625001526452531300261530ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/isophote/tests/data/synth_highsnr_table.fits000066400000000000000000000702001526452531300304740ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/isophote/tests/data/synth_lowsnr_table.fits000066400000000000000000000702001526452531300303560ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/isophote/tests/data/synth_table.fits000066400000000000000000000702001526452531300267520ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/isophote/tests/data/synth_table_mean.fits000066400000000000000000002354001526452531300277570ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/isophote/tests/test_ellipse.py000066400000000000000000000144011526452531300257050ustar00rootroot00000000000000# 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) # maxsma limits the runtime. The linear spacing checks below # do not depend on the outer extent of the fit. isophote_list = ellipse.fit_image(linear=True, step=2.0, maxsma=80.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_nearest_neighbor_zero_gradient(self): """ Regression test for a crash with the nearest_neighbor integration mode. At small semimajor axes, nearest-neighbor sampling can extract identical pixel sets at two nearby radii, making the local gradient exactly zero. The harmonic correctors would then divide by zero, producing a nonfinite geometry that crashed the integrator with "ValueError: cannot convert float NaN to integer". """ g = EllipseGeometry(256.0, 256.0, 20.0, 0.2, 0.0) ellipse = Ellipse(self.data, geometry=g) isophote_list = ellipse.fit_image(integrmode='nearest_neighbor', maxsma=30.0) assert isinstance(isophote_list, IsophoteList) assert len(isophote_list) > 1 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) match = 'No meaningful fit was possible' with pytest.warns(AstropyUserWarning, match=match): 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 go 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-24be529/photutils/isophote/tests/test_fitter.py000066400000000000000000000172731526452531300255570ustar00rootroot00000000000000# 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_zero_gradient_flat_image(): """ Test that the fitter handles a zero gradient without crashing. On a perfectly flat image, the nearest-neighbor integrator extracts identical pixel values at all radii, so the local gradient is exactly zero. Since no parameter corrections can be computed from a zero gradient, the fit should stop cleanly instead of dividing by zero and crashing on the resulting nonfinite geometry. """ data = np.ones((101, 101)) sample = EllipseSample(data, 10.0, integrmode='nearest_neighbor') fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.valid assert isophote.stop_code == -1 assert isophote.sample.gradient == 0.0 assert isophote.intens == 1.0 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-24be529/photutils/isophote/tests/test_geometry.py000066400000000000000000000130031526452531300261000ustar00rootroot00000000000000# 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-24be529/photutils/isophote/tests/test_harmonics.py000066400000000000000000000175051526452531300262430ustar00rootroot00000000000000# 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(): 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" and disky isophotes have b4 > 0 (boxy # isophotes have b4 < 0). assert np.all(isolist.b4[30:] > 0) # Check the sign of the upper harmonics at the outermost # well-sampled isophote. The very last isophote(s) can be # degenerate, with a large fraction of the elliptical path falling # outside the image, in which case the near-zero upper harmonics # are dominated by noise and their signs are not meaningful. n_total = isolist.n_data + isolist.n_flag well_sampled = isolist.n_flag < (0.3 * n_total) idx = np.where(well_sampled)[0][-1] assert isolist.a3[idx] < 0 assert isolist.a4[idx] < 0 assert isolist.b3[idx] > 0 assert isolist.b4[idx] > 0 astropy-photutils-24be529/photutils/isophote/tests/test_integrator.py000066400000000000000000000121651526452531300264330ustar00rootroot00000000000000# 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 subpixel 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-24be529/photutils/isophote/tests/test_isophote.py000066400000000000000000000310771526452531300261120ustar00rootroot00000000000000# 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning 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): # Compare 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 @pytest.mark.parametrize('columns', ['invalid', 'append', ['id', 'n_iter']]) def test_invalid_column(self, columns): 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) match = 'Invalid column name' with pytest.raises(ValueError, match=match): isolist.to_table(columns=columns) def test_deprecated_column(self): """ Regression test to ensure that deprecated column names are still accepted by ``to_table``, not rejected by the new column-name validation. """ 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) match = "'grad_error' attribute was deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match): tbl = isolist.to_table(columns=['grad_error']) assert tbl.colnames == ['gradient_err'] astropy-photutils-24be529/photutils/isophote/tests/test_model.py000066400000000000000000000245401526452531300253550ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model module. """ import warnings from concurrent.futures import ThreadPoolExecutor 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 numpy.testing import assert_array_equal from photutils.datasets.load import _get_path from photutils.isophote._ellipse_model import build_ellipse_model_c 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.fixture(name='simulated_isolist', scope='module') def fixture_simulated_isolist(): """ Fit isophotes to a simulated galaxy image. The fit does not depend on the model-building parameters, so it is performed only once and shared across tests. """ 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() return data, isophote_list @pytest.mark.parametrize('sma_interval', [0.05, 0.1]) def test_model_simulated_data(simulated_isolist, sma_interval): data, isophote_list = simulated_isolist 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 def test_build_ellipse_model_c_threadsafe(): """ Test that build_ellipse_model_c is thread-safe by running it in multiple threads and checking that the results are consistent. """ n = 64 sma = np.linspace(0.5, 20.0, n) intens = np.full(n, 1.0) eps = np.full(n, 0.3) pa = np.full(n, 0.5) x0 = np.full(n, 50.0) y0 = np.full(n, 50.0) def fn(): return build_ellipse_model_c(100, 100, sma, intens, eps, pa, x0, y0) expected = fn() n_threads = 8 n_calls_per_thread = 4 with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(fn) for _ in range(n_threads * n_calls_per_thread)] for future in futures: result = future.result() assert_array_equal(result[0], expected[0]) assert_array_equal(result[1], expected[1]) def test_build_ellipse_model_c_readonly_arrays(): """ Test that read-only (nonwritable) input arrays are accepted by build_ellipse_model_c and give results identical to writable arrays. """ n = 16 sma = np.linspace(0.5, 20.0, n) intens = np.full(n, 1.0) eps = np.full(n, 0.3) pa = np.full(n, 0.5) x0 = np.full(n, 50.0) y0 = np.full(n, 50.0) arrays = (sma, intens, eps, pa, x0, y0) expected = build_ellipse_model_c(100, 100, *arrays) arrays_ro = [] for arr in arrays: arr_ro = arr.copy() arr_ro.setflags(write=False) arrays_ro.append(arr_ro) result = build_ellipse_model_c(100, 100, *arrays_ro) assert_array_equal(result[0], expected[0]) assert_array_equal(result[1], expected[1]) def test_build_ellipse_model_c_noncontiguous_arrays(): """ Test that noncontiguous (strided) input arrays are accepted by build_ellipse_model_c and give results identical to contiguous arrays. The input arguments are declared as noncontiguous ``double[:]`` typed memoryviews. This test guards against a regression if they were ever changed to C-contiguous ``double[::1]`` memoryviews, which would reject strided arrays. """ n = 16 sma = np.linspace(0.5, 20.0, n) intens = np.full(n, 1.0) eps = np.full(n, 0.3) pa = np.full(n, 0.5) x0 = np.full(n, 50.0) y0 = np.full(n, 50.0) arrays = (sma, intens, eps, pa, x0, y0) expected = build_ellipse_model_c(100, 100, *arrays) # Build strided (noncontiguous) views by interleaving and slicing arrays_strided = [] for arr in arrays: strided = np.repeat(arr, 2)[::2] assert not strided.flags['C_CONTIGUOUS'] arrays_strided.append(strided) result = build_ellipse_model_c(100, 100, *arrays_strided) assert_array_equal(result[0], expected[0]) assert_array_equal(result[1], expected[1]) @pytest.mark.parametrize('index', range(5)) def test_build_ellipse_model_c_length_mismatch(index): """ Test that a ValueError is raised if any of the main input arrays does not have the same length as finely_spaced_sma. """ n = 16 sma = np.linspace(0.5, 20.0, n) arrays = [np.full(n, value) for value in (1.0, 0.3, 0.5, 50.0, 50.0)] arrays[index] = arrays[index][:-1] match = 'All input arrays must be same length' with pytest.raises(ValueError, match=match): build_ellipse_model_c(100, 100, sma, *arrays) @pytest.mark.parametrize('index', range(4)) def test_build_ellipse_model_c_harmonic_length_mismatch(index): """ Test that a ValueError is raised if any of the harmonic arrays does not have the same length as finely_spaced_sma. """ n = 16 sma = np.linspace(0.5, 20.0, n) arrays = [np.full(n, value) for value in (1.0, 0.3, 0.5, 50.0, 50.0)] harmonics = [np.full(n, 0.01) for _ in range(4)] harmonics[index] = harmonics[index][:-1] match = 'All input arrays must be same length' with pytest.raises(ValueError, match=match): build_ellipse_model_c(100, 100, sma, *arrays, *harmonics) def test_build_ellipse_model_c_partial_harmonics(): """ Test that a ValueError is raised if only some of the harmonic arrays are input. """ n = 16 sma = np.linspace(0.5, 20.0, n) arrays = [np.full(n, value) for value in (1.0, 0.3, 0.5, 50.0, 50.0)] match = 'Must supply all harmonic arrays if any is not None' with pytest.raises(ValueError, match=match): build_ellipse_model_c(100, 100, sma, *arrays, a3_array=np.full(n, 0.01)) astropy-photutils-24be529/photutils/isophote/tests/test_positional_kwargs.py000066400000000000000000000131101526452531300300030ustar00rootroot00000000000000# 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 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning class TestEllipsePositionalKwargs: """ Test Ellipse.__init__, fit_image, fit_isophote for positional optional args. """ def setup_method(self): # A small image with a capped fit is sufficient (and fast) # for testing the deprecation warnings self.data = make_test_image(nx=128, ny=128, sma=10.0, seed=0) def test_init_positional_warns(self): geometry = EllipseGeometry(x0=64, y0=64, sma=10, eps=0.2, pa=np.pi / 2) match = '__init__' with pytest.warns(PhotutilsDeprecationWarning, match=match): Ellipse(self.data, geometry) def test_init_keyword_no_warning(self): geometry = EllipseGeometry(x0=64, y0=64, 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(PhotutilsDeprecationWarning, match=match): ellipse.fit_image(10.0, maxsma=15.0) def test_fit_image_keyword_no_warning(self): ellipse = Ellipse(self.data) ellipse.fit_image(sma0=10.0, maxsma=15.0) def test_fit_isophote_positional_warns(self): ellipse = Ellipse(self.data) match = 'fit_isophote' with pytest.warns(PhotutilsDeprecationWarning, match=match): ellipse.fit_isophote(10.0, 0.1) def test_fit_isophote_keyword_no_warning(self): ellipse = Ellipse(self.data) ellipse.fit_isophote(10.0, step=0.1) class TestEllipseGeometryPositionalKwargs: """ Test EllipseGeometry.__init__ and find_center. """ def test_init_positional_warns(self): match = '__init__' with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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) @pytest.fixture(name='small_isolist', scope='module') def fixture_small_isolist(): """ Fit isophotes to a small simulated galaxy image. A small image with a capped fit is sufficient (and fast) for testing the deprecation warnings, and the fit is shared across tests. """ data = make_test_image(nx=128, ny=128, sma=10.0, seed=0) ellipse = Ellipse(data) isolist = ellipse.fit_image(sma0=10.0, maxsma=15.0) return data.shape, isolist class TestBuildEllipseModelPositionalKwargs: """ Test build_ellipse_model. """ def test_positional_warns(self, small_isolist): shape, isolist = small_isolist match = 'build_ellipse_model' with pytest.warns(PhotutilsDeprecationWarning, match=match): build_ellipse_model(shape, isolist, 0.0) def test_keyword_no_warning(self, small_isolist): shape, isolist = small_isolist build_ellipse_model(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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/isophote/tests/test_regression.py000066400000000000000000000203251526452531300264320ustar00rootroot00000000000000# 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 semimajor 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 nonexistent 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-24be529/photutils/isophote/tests/test_sample.py000066400000000000000000000055711526452531300255410ustar00rootroot00000000000000# 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.geometry import EllipseGeometry 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) def test_geometry_sma_override_resets_cached_attributes(): geometry = EllipseGeometry(25.0, 25.0, 10.0, 0.3, np.pi / 4.0, astep=0.2) sample = EllipseSample(DATA, 20.0, geometry=geometry) expected_geometry = EllipseGeometry(25.0, 25.0, 20.0, 0.3, np.pi / 4.0, astep=0.2) assert sample.geometry.sma == 20.0 assert geometry.sma == 10.0 np.testing.assert_allclose(sample.geometry._area_factor, expected_geometry._area_factor) np.testing.assert_allclose(sample.geometry.sector_angular_width, expected_geometry.sector_angular_width) np.testing.assert_allclose(sample.geometry.initial_polar_angle, expected_geometry.initial_polar_angle) np.testing.assert_allclose(sample.geometry.initial_polar_radius, expected_geometry.initial_polar_radius) astropy-photutils-24be529/photutils/morphology/000077500000000000000000000000001526452531300220425ustar00rootroot00000000000000astropy-photutils-24be529/photutils/morphology/__init__.py000066400000000000000000000004131526452531300241510ustar00rootroot00000000000000# 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-24be529/photutils/morphology/core.py000066400000000000000000000103501526452531300233430ustar00rootroot00000000000000# 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. Nonfinite values (NaN and inf) are automatically masked. If ``data`` is a `~numpy.ma.MaskedArray`, its mask is combined with the input ``mask``. mask : array_like (bool), optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. background : float, array_like, or `~astropy.units.Quantity`, optional The background level previously present in the input ``data``. ``background`` may be a scalar value or a 2D array with the same shape as ``data``. If ``data`` is a `~astropy.units.Quantity`, then ``background`` must have the same units. The input ``background`` is not subtracted from ``data``, which should already be background-subtracted. Providing it only enables background-related properties to be measured. If `None`, then all background properties will be NaN. 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 ``data`` is empty. 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_mask = None if isinstance(data, np.ma.MaskedArray): data_mask = np.ma.getmaskarray(data) data = data.data data = np.asanyarray(data) if data.ndim != 2: msg = 'data must be a 2D array' raise ValueError(msg) if data.size == 0: msg = 'data must not be empty' 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 data_mask is not None: mask = mask | data_mask else: mask = data_mask if mask is not None: 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: # asanyarray and the multiplication below preserve Quantity # units background = np.asanyarray(background) if background.ndim == 0: background = np.full(data.shape, 1.0) * 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-24be529/photutils/morphology/non_parametric.py000066400000000000000000000074351526452531300254260ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for measuring nonparametric 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_{i=1}^{n} (2i - n - 1) \left | x_i \right | where the :math:`n` pixel values :math:`x_i` are sorted in order of increasing absolute value and :math:`\overline{|x|}` is the mean of their absolute values. If all pixel values are 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. Measuring the Gini coefficient usually requires some preprocessing to define the galaxy region in the image, based on the quality of the input data. There is no general standard for doing this, so it is left to 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. If no finite, unmasked pixels remain, NaN is returned. Parameters ---------- data : array_like The data array or object that can be converted to an array. The array may have any dimensionality (e.g., a 1D array or a 2D image), with its values treated as a flattened set. If ``data`` is a `~numpy.ma.MaskedArray`, its mask is combined with the input ``mask``. mask : array_like (bool), 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. NaN is returned if there are no finite, unmasked values. Raises ------ ValueError If ``mask`` is provided and does not have the same shape as ``data``. """ data_mask = None if isinstance(data, np.ma.MaskedArray): data_mask = np.ma.getmaskarray(data) data = data.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) if data_mask is not None: mask = mask | data_mask else: mask = data_mask values = np.ravel(data) if mask is None else np.ravel(data[~mask]) # Exclude invalid values (NaN, inf) values = np.abs(values[np.isfinite(values)]) n_pixels = values.size if n_pixels == 0: return np.nan if n_pixels == 1: return 0.0 normalization = np.mean(values) * n_pixels * (n_pixels - 1) if normalization == 0.0: return 0.0 kernel = ((2.0 * np.arange(1, n_pixels + 1) - n_pixels - 1) * np.sort(values)) return np.sum(kernel) / normalization astropy-photutils-24be529/photutils/morphology/tests/000077500000000000000000000000001526452531300232045ustar00rootroot00000000000000astropy-photutils-24be529/photutils/morphology/tests/__init__.py000066400000000000000000000000001526452531300253030ustar00rootroot00000000000000astropy-photutils-24be529/photutils/morphology/tests/test_core.py000066400000000000000000000117371526452531300255560ustar00rootroot00000000000000# 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_quantity_bkg(): """ Test that a ``~astropy.units.Quantity`` background preserves its units. """ data = np.ones((3, 3)) * u.Jy props = data_properties(data, background=1.0 * u.Jy) assert props.background_sum == 9.0 * u.Jy bkg_2d = np.full((3, 3), 2.0) * u.Jy props2 = data_properties(data, background=bkg_2d) assert props2.background_sum == 18.0 * u.Jy def test_data_properties_empty_data(): """ Test that empty data raises ``ValueError``. """ match = 'data must not be empty' with pytest.raises(ValueError, match=match): data_properties(np.ones((0, 5))) with pytest.raises(ValueError, match=match): data_properties(np.ones((3, 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_masked_array(): """ Test that the mask of a masked array is used and is combined with the ``mask`` keyword. """ rng = np.random.default_rng(0) data = rng.random((11, 15)) + 1.0 mask1 = np.zeros(data.shape, dtype=bool) mask1[2:5, 3:7] = True mask2 = np.zeros(data.shape, dtype=bool) mask2[8:, :4] = True data_ma = np.ma.MaskedArray(data, mask=mask1) props1 = data_properties(data_ma) props2 = data_properties(data, mask=mask1) assert props1.area == props2.area assert_allclose(props1.centroid, props2.centroid) assert_allclose(props1.segment_flux, props2.segment_flux) props1 = data_properties(data_ma, mask=mask2) props2 = data_properties(data, mask=mask1 | mask2) assert props1.area == props2.area assert_allclose(props1.centroid, props2.centroid) data_ma = np.ma.MaskedArray(data, mask=True) match = 'All pixels in data are masked' with pytest.raises(ValueError, match=match): data_properties(data_ma) 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-24be529/photutils/morphology/tests/test_non_parametric.py000066400000000000000000000114651526452531300276250ustar00rootroot00000000000000# 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_nd(): """ Test that arrays of any dimensionality are accepted and treated as a flattened set of values. """ data_3d = np.zeros((3, 3, 3)) data_3d[1, 1, 1] = 1.0 assert gini(data_3d) == 1.0 assert gini(data_3d) == gini(data_3d.ravel()) 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_masked_array(): """ Test that the mask of a masked array is used and is combined with the ``mask`` keyword. """ rng = np.random.default_rng(0) data = rng.random((20, 20)) data[0, 0] = 1000.0 data[5, 5] = 500.0 mask1 = np.zeros(data.shape, dtype=bool) mask1[0, 0] = True mask2 = np.zeros(data.shape, dtype=bool) mask2[5, 5] = True data_ma = np.ma.MaskedArray(data, mask=mask1) assert gini(data_ma) == gini(data, mask=mask1) assert gini(data_ma) != gini(data) assert gini(data_ma, mask=mask2) == gini(data, mask=mask1 | mask2) data_ma = np.ma.MaskedArray(data, mask=True) assert np.isnan(gini(data_ma)) 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 a 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) give a result strictly between the # 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 # A monotonically increasing gradient array gives a 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-24be529/photutils/morphology/tests/test_positional_kwargs.py000066400000000000000000000025461526452531300303630ustar00rootroot00000000000000# 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 photutils.morphology import data_properties, gini from photutils.utils.exceptions import PhotutilsDeprecationWarning 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/morphology/tests/test_thread_safety.py000066400000000000000000000070621526452531300274440ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the morphology functions are thread-safe and return identical results when run concurrently from multiple threads. Both ``data_properties`` and ``gini`` are pure functions with no module-level mutable state. Each call operates only on its inputs and local variables, and ``data_properties`` constructs a new ``SourceCatalog`` per call. """ from concurrent.futures import ThreadPoolExecutor import numpy as np from numpy.testing import assert_array_equal from photutils.morphology import data_properties, gini N_THREADS = 8 N_CALLS_PER_THREAD = 4 DATA_PROPERTIES_COLUMNS = ['x_centroid', 'y_centroid', 'semimajor_axis', 'semiminor_axis', 'orientation', 'area'] def make_shared_inputs(): """ Return a shared image, mask, and background for the concurrent calls. Returns ------- data : 2D `~numpy.ndarray` The image. mask : 2D `~numpy.ndarray` A boolean mask. background : 2D `~numpy.ndarray` A background image. """ rng = np.random.default_rng(0) data = rng.random((51, 51)) data[20:30, 20:30] += 50.0 mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True background = np.full(data.shape, 0.5) return data, mask, background def run_concurrently(task, expected): """ Run ``task`` concurrently and check each result against ``expected``. Parameters ---------- task : callable The zero-argument callable to run in the thread pool. expected : tuple The expected result from a serial call. """ with ThreadPoolExecutor(max_workers=N_THREADS) as executor: futures = [executor.submit(task) for _ in range(N_THREADS * N_CALLS_PER_THREAD)] for future in futures: assert_array_equal(future.result(), expected) def test_concurrent_data_properties(): """ Test that concurrent data_properties calls on shared input arrays return results identical to a serial call. """ data, mask, background = make_shared_inputs() def task(): props = data_properties(data, mask=mask, background=background) values = [getattr(props, name).value for name in DATA_PROPERTIES_COLUMNS if name not in ('x_centroid', 'y_centroid')] values += [props.x_centroid, props.y_centroid, props.background_sum] return values run_concurrently(task, task()) def test_concurrent_gini(): """ Test that concurrent gini calls on shared input arrays return results identical to a serial call. """ data, mask, _ = make_shared_inputs() def task(): return gini(data, mask=mask) run_concurrently(task, task()) def test_mixed_concurrent_calls(): """ Test data_properties and gini running interleaved in one thread pool. """ data, mask, _ = make_shared_inputs() def gini_task(): return gini(data, mask=mask) def props_task(): props = data_properties(data, mask=mask) return [props.x_centroid, props.y_centroid, props.area.value] tasks = {'gini': gini_task, 'data_properties': props_task} expected = {name: task() for name, task in tasks.items()} with ThreadPoolExecutor(max_workers=N_THREADS) as executor: futures = [(name, executor.submit(task)) for name, task in tasks.items() for _ in range(N_THREADS)] for name, future in futures: assert_array_equal(future.result(), expected[name]) astropy-photutils-24be529/photutils/profiles/000077500000000000000000000000001526452531300214665ustar00rootroot00000000000000astropy-photutils-24be529/photutils/profiles/__init__.py000066400000000000000000000004471526452531300236040ustar00rootroot00000000000000# 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-24be529/photutils/profiles/core.py000066400000000000000000000403211526452531300227700ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Base class for profiles. """ import abc import warnings from functools import cached_property import astropy.units as u import numpy as np from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture._common import validate_mask_method from photutils.aperture.core import _update_method_subpixels_docstring from photutils.aperture.photometry import AperturePhotometry 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'] @_update_method_subpixels_docstring class ProfileBase(metaclass=abc.ABCMeta): # numpydoc ignore: PR01,PR02,PR04,PR07 """ Abstract base class for profile classes. Parameters ---------- data : 2D `~numpy.ndarray` or `~astropy.units.Quantity` The 2D data array. The data should be background-subtracted. Nonfinite 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 array_like of float 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. ``radii`` must be in pixels and cannot be a `~astropy.units.Quantity`. See the subclass documentation for details on how ``radii`` is interpreted. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, 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``. If ``data`` is a `~astropy.units.Quantity`, then ``error`` must have the same units. 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. Raises ------ TypeError If ``radii`` is a `~astropy.units.Quantity`. ValueError If ``data`` is not a 2D array, if ``radii`` is not a 1D array with at least two values, has a negative value, or is not strictly increasing, if ``error`` or ``mask`` does not have the same shape as ``data``, if ``data`` and ``error`` do not have the same units, or if ``method`` or ``subpixels`` is invalid. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the ``data`` or ``error`` array contains nonfinite values that are not already masked by ``mask``. """ # Define axis labels used by `~photutils.profiles.ProfileBase.plot`. # Subclasses may override these. _xlabel = 'Radius (pixels)' _ylabel = 'Profile' # The user-facing name of the ``radii`` parameter, used in # validation error messages. Subclasses that rename the parameter # (e.g., ``half_sizes``) may override this. _radii_name = 'radii' def __init__(self, data, xycen, radii, *, error=None, mask=None, method='exact', subpixels=5): (data, error), unit = process_quantities((data, error), ('data', 'error')) if np.ndim(data) != 2: msg = 'data must be a 2D array' raise ValueError(msg) validate_mask_method(method, subpixels, method_name='method') 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 # The total normalization applied to the profile. This is the # only mutable state of the class. All normalization-dependent # attributes (e.g., ``profile``) are derived from immutable # cached values divided by this value, and `normalize` and # `unnormalize` update it with a single atomic attribute # store, making the class safe for concurrent reads during # normalization changes. self.normalization_value = 1.0 def _validate_radii(self, radii): """ Validate and return the radii array. """ name = self._radii_name if isinstance(radii, u.Quantity): msg = (f'{name} must be a plain array of pixel values, ' 'not a Quantity') raise TypeError(msg) radii = np.array(radii) if radii.ndim != 1 or radii.size < 2: msg = f'{name} must be a 1D array and have at least two values' raise ValueError(msg) if radii.min() < 0: msg = f'minimum {name} must be >= 0' raise ValueError(msg) if not np.all(radii[1:] > radii[:-1]): msg = f'{name} must be strictly increasing' raise ValueError(msg) return radii def _compute_mask(self, data, error, mask): """ Compute the mask array, automatically masking nonfinite 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 nonfinite 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 nonfinite 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 _raw_profile(self): """ The raw (unnormalized) profile as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. """ @property @abc.abstractmethod def _raw_profile_error(self): """ The raw (unnormalized) profile errors as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ @property def profile(self): """ The profile as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. The returned values reflect the current profile normalization (see `normalize`). A normalized profile of data with units is a dimensionless `~astropy.units.Quantity`. """ return self._raw_profile / self.normalization_value @property def profile_error(self): """ The profile errors as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. The returned values reflect the current profile normalization (see `normalize`). If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ return self._raw_profile_error / self.normalization_value @cached_property 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 using a single batched photometry call. Parameters ---------- apertures : list A list of aperture objects sharing the same position. Leading elements may be `None` (e.g., for a zero radius), in which case the corresponding flux, error, and area are set to zero. Returns ------- flux : `~numpy.ndarray` or `~astropy.units.Quantity` The aperture fluxes. A `~astropy.units.Quantity` if the input ``data`` has units. flux_err : `~numpy.ndarray` or `~astropy.units.Quantity` The aperture flux errors, or an empty array if no ``error`` array was provided. areas : `~numpy.ndarray` The aperture areas in pixels squared, without units. """ n_none = sum(aperture is None for aperture in apertures) result = AperturePhotometry( self.data, apertures[n_none:], error=self.error, mask=self.mask, method=self.method, subpixels=self.subpixels) fluxes = result.flux areas = result.area.to_value(u.pix ** 2) flux_errs = (result.flux_err if self.error is not None else np.array([])) if n_none > 0: zeros = np.zeros(n_none) fluxes = np.concatenate((zeros, fluxes)) areas = np.concatenate((zeros, areas)) if self.error is not None: flux_errs = np.concatenate((zeros, flux_errs)) if self.unit is not None: fluxes = fluxes << self.unit flux_errs = flux_errs << self.unit return fluxes, flux_errs, areas @cached_property 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. The profile errors (`profile_error`) are divided by the same normalization value as the profile. The normalization is computed from the raw (unnormalized) profile values, so repeated calls do not accumulate. The most recent call determines the normalization. Because both normalization methods scale linearly with the profile values, this is equivalent to normalizing an already-normalized profile. NaN values in the profile are ignored when computing the maximum or sum. 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 the sum of its values is 1. Raises ------ ValueError If ``method`` is not ``'max'`` or ``'sum'``. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the maximum or sum of the profile is not positive or is nonfinite. In that case, the current normalization is left unchanged. """ if method == 'max': func = nanmax elif method == 'sum': func = nansum else: msg = "invalid method, must be 'max' or 'sum'" raise ValueError(msg) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) normalization = func(self._raw_profile) if not (np.isfinite(normalization) and normalization > 0): msg = ('The profile cannot be normalized because the max or ' 'sum is not positive or is nonfinite.') warnings.warn(msg, AstropyUserWarning) else: # A single atomic attribute store. Concurrent readers see # either the old or the new normalization, never a mixed # state. self.normalization_value = normalization def unnormalize(self): """ Unnormalize the profile back to the original state before any calls to `normalize`. """ self.normalization_value = 1.0 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() profile = self.profile unit = None if isinstance(profile, u.Quantity): unit = profile.unit profile = profile.value lines = ax.plot(self.radius, profile, **kwargs) ax.set_xlabel(self._xlabel) ylabel = self._ylabel # A normalized profile is dimensionless, so the unit is # included only when the profile has a physical unit if unit is not None and unit != u.dimensionless_unscaled: ylabel = f'{ylabel} ({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`. If ``facecolor`` is not given, a semi-transparent gray is used. Returns ------- poly : `matplotlib.collections.PolyCollection` or `None` A `~matplotlib.collections.PolyCollection` containing the plotted polygons, or `None` if no errors were input. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If no errors were input. In that case, nothing is plotted and `None` is returned. """ 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 isinstance(profile, u.Quantity): profile = profile.value if isinstance(profile_error, u.Quantity): profile_error = profile_error.value ymin = profile - profile_error ymax = profile + profile_error return ax.fill_between(self.radius, ymin, ymax, **kws) astropy-photutils-24be529/photutils/profiles/curve_of_growth.py000066400000000000000000001270571526452531300252560ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating curves of growth. """ import abc from functools import cached_property import astropy.units as u import numpy as np from scipy.interpolate import PchipInterpolator from photutils.aperture.core import _update_method_subpixels_docstring from photutils.profiles.core import ProfileBase __all__ = ['CurveOfGrowth', 'EllipticalCurveOfGrowth', 'EnsquaredCurveOfGrowth'] class _CurveOfGrowthBase(ProfileBase): """ Base class for curve-of-growth profile classes. Curve-of-growth profiles measure the cumulative flux directly within the apertures defined by the ``apertures`` property. """ # The name of the size argument of the interpolation methods, used # in validation error messages. Subclasses may override this. _size_name = 'radius' @property @abc.abstractmethod def apertures(self): """ A list of the aperture objects used to measure the profile. """ @cached_property def _photometry(self): """ The aperture fluxes, flux errors, and areas as a function of radius. """ return self._compute_photometry(self.apertures) @cached_property def _raw_profile(self): """ The raw (unnormalized) curve-of-growth profile as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. """ return self._photometry[0] @cached_property def _raw_profile_error(self): """ The raw (unnormalized) curve-of-growth profile errors as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. If no ``error`` array was provided, an empty array with shape ``(0,)`` is returned. """ return self._photometry[1] @cached_property def area(self): """ The unmasked area within each aperture as a 1D `~numpy.ndarray`. """ return self._photometry[2] def _finite_profile(self): """ The finite profile values used by the interpolation methods. Returns ------- radius : 1D `~numpy.ndarray` The profile radii with a finite profile value. profile : 1D `~numpy.ndarray` The finite profile values, without units. unit : `~astropy.units.Unit` or `None` The profile unit. """ profile = self.profile unit = getattr(profile, 'unit', None) if unit is not None: profile = profile.value finite = np.isfinite(profile) if np.count_nonzero(finite) < 2: msg = ('The curve-of-growth profile must have at least two ' 'finite values to be interpolated.') raise ValueError(msg) return self.radius[finite], profile[finite], unit def _calc_ee(self, radius): """ Calculate the enclosed energy at the given radii by interpolating the profile. """ if isinstance(radius, u.Quantity): msg = (f'{self._size_name} must be in pixels and cannot be a ' 'Quantity') raise TypeError(msg) xvals, profile, unit = self._finite_profile() ee = PchipInterpolator(xvals, profile, extrapolate=False)(radius) if unit is not None: ee = ee << unit return ee def _calc_size(self, ee): """ Calculate the smallest radius at which the interpolated profile reaches the given enclosed energies. """ xvals, profile, unit = self._finite_profile() if isinstance(ee, u.Quantity): if unit is None: unit = u.dimensionless_unscaled ee = ee.to_value(unit) ee = np.asarray(ee, dtype=float) # The interpolant is monotonic between adjacent profile values, # so it first reaches a value in the first interval whose end # values bracket it lower = np.minimum(profile[:-1], profile[1:]) upper = np.maximum(profile[:-1], profile[1:]) interp = PchipInterpolator(xvals, profile, extrapolate=False) result = np.full(ee.shape, np.nan) for idx, value in np.ndenumerate(ee): bracket = (lower <= value) & (value <= upper) if not np.any(bracket): continue # outside the profile range, or NaN first = np.argmax(bracket) if profile[first] == value: result[idx] = xvals[first] elif profile[first + 1] == value: result[idx] = xvals[first + 1] else: roots = interp.solve(value, extrapolate=False) inside = ((roots >= xvals[first]) & (roots <= xvals[first + 1])) result[idx] = roots[inside].min() return result @_update_method_subpixels_docstring class CurveOfGrowth(_CurveOfGrowthBase): # numpydoc ignore: PR01,PR02,PR04,PR07 """ 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` or `~astropy.units.Quantity` The 2D data array. The data should be background-subtracted. Nonfinite 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 array_like of float 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. ``radii`` must be in pixels and cannot be a `~astropy.units.Quantity`. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, 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``. If ``data`` is a `~astropy.units.Quantity`, then ``error`` must have the same units. Nonfinite 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. Raises ------ TypeError If ``radii`` is a `~astropy.units.Quantity`. ValueError If ``data`` is not a 2D array, if ``radii`` is not a 1D array with at least two values, has a value less than or equal to zero, or is not strictly increasing, if ``error`` or ``mask`` does not have the same shape as ``data``, if ``data`` and ``error`` do not have the same units, or if ``method`` or ``subpixels`` is invalid. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the ``data`` or ``error`` array contains nonfinite values that are not already masked by ``mask``. 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) [ 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) [ 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) [ 3.11062819 6.83127792 10.55630757 14.27852358 18.00330858 21.72296997 25.44442261 29.16767174 32.88949473 36.61224389 40.33235754 44.0555786 47.77789677 51.49947445 55.22141896 58.94275538 62.66630873 66.38766913 70.10917057 73.83146673 77.55360137 81.27674225 84.99747377 88.71963388 92.44198203] 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 three 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) @cached_property 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 @cached_property def apertures(self): """ A list of `~photutils.aperture.CircularAperture` objects used to measure the profile. """ return self._circular_apertures 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 the input data have 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 such that its maximum value is 1. For noisy data, the maximum may not occur at the largest input ``radii``. The interpolated curve passes through the measured profile values. For a noisy profile, the curve can therefore decrease with radius where the enclosed flux has leveled off. Parameters ---------- radius : float or array_like The circular radius/radii in pixels. It cannot be a `~astropy.units.Quantity`. Returns ------- ee : `~numpy.ndarray` or `~astropy.units.Quantity` The encircled energy at each radius. It is a `~astropy.units.Quantity` with the units of `profile` if the input ``data`` has units. Returns NaN for radii outside the range of the profile data. Raises ------ TypeError If ``radius`` is a `~astropy.units.Quantity`. See Also -------- calc_radius_at_ee """ return self._calc_ee(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 the input data have 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 such that its maximum value is 1. For noisy data, the maximum may not occur at the largest input ``radii``. This method returns the smallest radius at which the curve interpolated by `calc_ee_at_radius` reaches the encircled energy. Converting the result back with `calc_ee_at_radius` returns the input encircled energy. For a noisy profile, the curve can reach the same encircled energy again at a larger radius. Where a noisy profile has leveled off, the radius of a given encircled energy is poorly constrained, and the maximum used by `normalize` is biased high by the noise. If you need the radius at a large encircled energy (e.g., 0.95 or more), use a profile with a high signal-to-noise ratio, or divide the input ``data`` by a known total flux instead of calling `normalize`. Parameters ---------- ee : float, array_like, or `~astropy.units.Quantity` The encircled energy. A value without units is assumed to be in the units of `profile`. A `~astropy.units.Quantity` is converted to the units of `profile`, and must be dimensionless if `profile` has no units. Returns ------- radius : `~numpy.ndarray` The radius in pixels at each encircled energy. Returns NaN for encircled energies outside the range of the profile data. Raises ------ `~astropy.units.UnitConversionError` If ``ee`` is a `~astropy.units.Quantity` with units that cannot be converted to the units of `profile`. See Also -------- calc_ee_at_radius """ return self._calc_size(ee) @_update_method_subpixels_docstring class EnsquaredCurveOfGrowth(_CurveOfGrowthBase): # numpydoc ignore: PR01,PR02,PR04,PR07 """ 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` or `~astropy.units.Quantity` The 2D data array. The data should be background-subtracted. Nonfinite 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 array_like of float 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``. ``half_sizes`` must be in pixels and cannot be a `~astropy.units.Quantity`. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, 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``. If ``data`` is a `~astropy.units.Quantity`, then ``error`` must have the same units. Nonfinite 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. Raises ------ TypeError If ``half_sizes`` is a `~astropy.units.Quantity`. ValueError If ``data`` is not a 2D array, if ``half_sizes`` is not a 1D array with at least two values, has a value less than or equal to zero, or is not strictly increasing, if ``error`` or ``mask`` does not have the same shape as ``data``, if ``data`` and ``error`` do not have the same units, or if ``method`` or ``subpixels`` is invalid. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the ``data`` or ``error`` array contains nonfinite values that are not already masked by ``mask``. 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) [ 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) [ 171.51682931 640.93965938 1328.92447125 2143.49339746 2954.67817356 3718.43574763 4357.67409109 4845.38866928 5199.93124392 5481.4874771 5641.60852855 5752.43870109 5791.01446371 5819.46039533 5832.44691816 5825.39056549 5834.14890108 5833.42003487 5852.25143102 5856.59671815 5869.46227464 5872.94734439 5868.92606685 5849.96903786 5839.55875017] >>> print(ecog.profile_error) [ 3.57264883 7.77582626 11.97677454 16.17723038 20.3774983 24.57767465 28.77779953 32.97789259 37.17796463 41.37802205 45.57806888 49.77810781 53.97814068 58.17816881 62.37819315 66.57821441 70.77823316 74.9782498 79.17826468 83.37827806 87.57829015 91.77830114 95.97831117 100.17832036 104.3783288 ] 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 three 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' # The user-facing name of the sizes parameter, used in validation # error messages _radii_name = 'half_sizes' _size_name = 'half_size' 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) # Alias the validated sizes under the user-facing name. The # inherited ``radii`` attribute holds the same array. 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})') @cached_property 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 @cached_property 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 @cached_property 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] 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 the input data have 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 such that its maximum value is 1. For noisy data, the maximum may not occur at the largest input ``half_sizes``. The interpolated curve passes through the measured profile values. For a noisy profile, the curve can therefore decrease with half-size where the enclosed flux has leveled off. Parameters ---------- half_size : float or array_like The square half side length(s) in pixels. It cannot be a `~astropy.units.Quantity`. Returns ------- ee : `~numpy.ndarray` or `~astropy.units.Quantity` The ensquared energy at each half-size. It is a `~astropy.units.Quantity` with the units of `profile` if the input ``data`` has units. Returns NaN for half-sizes outside the range of the profile data. Raises ------ TypeError If ``half_size`` is a `~astropy.units.Quantity`. See Also -------- calc_half_size_at_ee """ return self._calc_ee(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 the input data have 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 such that its maximum value is 1. For noisy data, the maximum may not occur at the largest input ``half_sizes``. This method returns the smallest half-size at which the curve interpolated by `calc_ee_at_half_size` reaches the ensquared energy. Converting the result back with `calc_ee_at_half_size` returns the input ensquared energy. For a noisy profile, the curve can reach the same ensquared energy again at a larger half-size. Where a noisy profile has leveled off, the half-size of a given ensquared energy is poorly constrained, and the maximum used by `normalize` is biased high by the noise. If you need the half-size at a large ensquared energy (e.g., 0.95 or more), use a profile with a high signal-to-noise ratio, or divide the input ``data`` by a known total flux instead of calling `normalize`. Parameters ---------- ee : float, array_like, or `~astropy.units.Quantity` The ensquared energy. A value without units is assumed to be in the units of `profile`. A `~astropy.units.Quantity` is converted to the units of `profile`, and must be dimensionless if `profile` has no units. Returns ------- half_size : `~numpy.ndarray` The half-size in pixels at each ensquared energy. Returns NaN for ensquared energies outside the range of the profile data. Raises ------ `~astropy.units.UnitConversionError` If ``ee`` is a `~astropy.units.Quantity` with units that cannot be converted to the units of `profile`. See Also -------- calc_ee_at_half_size """ return self._calc_size(ee) @_update_method_subpixels_docstring class EllipticalCurveOfGrowth(_CurveOfGrowthBase): # numpydoc ignore: PR01,PR02,PR04,PR07 """ 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` or `~astropy.units.Quantity` The 2D data array. The data should be background-subtracted. Nonfinite 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 array_like of float 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. ``radii`` must be in pixels and cannot be a `~astropy.units.Quantity`. 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. The default is 0. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, 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``. If ``data`` is a `~astropy.units.Quantity`, then ``error`` must have the same units. Nonfinite 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. Raises ------ TypeError If ``radii`` is a `~astropy.units.Quantity`. ValueError If ``data`` is not a 2D array, if ``radii`` is not a 1D array with at least two values, has a value less than or equal to zero, or is not strictly increasing, if ``axis_ratio`` is not in the range ``0 < axis_ratio <= 1``, if ``error`` or ``mask`` does not have the same shape as ``data``, if ``data`` and ``error`` do not have the same units, or if ``method`` or ``subpixels`` is invalid. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the ``data`` or ``error`` array contains nonfinite values that are not already masked by ``mask``. 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) [ 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) [ 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) [ 1.82558544 4.4811361 7.21875141 9.88960965 12.44181885 15.0767893 17.74361162 20.3959057 23.02040853 25.60886742 28.25910457 30.93376937 33.54721095 36.14973269 38.78350009 41.44751786 44.08722476 46.67911616 49.30783947 51.9760285 54.61090354 57.21854088 59.84454217 62.48828381 65.14297876 67.7591664 70.36843954 73.01750591 75.66115523 78.29244888 80.90838345 83.53056346 86.19047387 88.82463241 91.43141788 94.06294498 96.71101089 99.35484622 101.96990083] 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 three 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) 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'axis_ratio={self.axis_ratio}, theta={self.theta}, ' f'normalized={normalized})') @cached_property 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 @cached_property 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] 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 the input data have 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 such that its maximum value is 1. For noisy data, the maximum may not occur at the largest input ``radii``. The interpolated curve passes through the measured profile values. For a noisy profile, the curve can therefore decrease with semimajor-axis length where the enclosed flux has leveled off. Parameters ---------- radius : float or array_like The semimajor-axis length(s) in pixels. It cannot be a `~astropy.units.Quantity`. Returns ------- ee : `~numpy.ndarray` or `~astropy.units.Quantity` The encircled energy at each semimajor-axis length. It is a `~astropy.units.Quantity` with the units of `profile` if the input ``data`` has units. Returns NaN for semimajor-axis lengths outside the range of the profile data. Raises ------ TypeError If ``radius`` is a `~astropy.units.Quantity`. See Also -------- calc_radius_at_ee """ return self._calc_ee(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 the input data have 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 such that its maximum value is 1. For noisy data, the maximum may not occur at the largest input ``radii``. This method returns the smallest semimajor-axis length at which the curve interpolated by `calc_ee_at_radius` reaches the encircled energy. Converting the result back with `calc_ee_at_radius` returns the input encircled energy. For a noisy profile, the curve can reach the same encircled energy again at a larger semimajor-axis length. Where a noisy profile has leveled off, the semimajor-axis length of a given encircled energy is poorly constrained, and the maximum used by `normalize` is biased high by the noise. If you need the semimajor-axis length at a large encircled energy (e.g., 0.95 or more), use a profile with a high signal-to-noise ratio, or divide the input ``data`` by a known total flux instead of calling `normalize`. Parameters ---------- ee : float, array_like, or `~astropy.units.Quantity` The encircled energy. A value without units is assumed to be in the units of `profile`. A `~astropy.units.Quantity` is converted to the units of `profile`, and must be dimensionless if `profile` has no units. Returns ------- radius : `~numpy.ndarray` The semimajor-axis length in pixels at each encircled energy. Returns NaN for encircled energies outside the range of the profile data. Raises ------ `~astropy.units.UnitConversionError` If ``ee`` is a `~astropy.units.Quantity` with units that cannot be converted to the units of `profile`. See Also -------- calc_ee_at_radius """ return self._calc_size(ee) astropy-photutils-24be529/photutils/profiles/radial_profile.py000066400000000000000000000612701526452531300250220ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating radial profiles. """ import warnings from functools import cached_property 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.exceptions import AstropyUserWarning from photutils.aperture.core import _update_method_subpixels_docstring from photutils.profiles.core import ProfileBase __all__ = ['RadialProfile'] @_update_method_subpixels_docstring class RadialProfile(ProfileBase): """ Class to create a radial profile using concentric circular annulus apertures. The radial profile represents the azimuthally-averaged flux in circular annulus 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` or `~astropy.units.Quantity` The 2D data array. The data should be background-subtracted. Nonfinite 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 array_like of float 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. ``radii`` must be in pixels and cannot be a `~astropy.units.Quantity`. The output `radius` attribute will be defined at the bin centers. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, 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``. If ``data`` is a `~astropy.units.Quantity`, then ``error`` must have the same units. Nonfinite 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. Raises ------ TypeError If ``radii`` is a `~astropy.units.Quantity`. ValueError If ``data`` is not a 2D array, if ``radii`` is not a 1D array with at least two values, has a negative value, or is not strictly increasing, if ``error`` or ``mask`` does not have the same shape as ``data``, if ``data`` and ``error`` do not have the same units, or if ``method`` or ``subpixels`` is invalid. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the ``data`` or ``error`` array contains nonfinite values that are not already masked by ``mask``. 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. A radial bin whose pixels are all masked has zero unmasked area, and its profile and profile error values are NaN. 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) [ 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) [ 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) [0.9901437 0.64531698 0.51234751 0.43720335 0.38782635 0.35176132 0.32440232 0.3025968 0.28456077 0.26947982 0.25644723 0.24531375 0.23540592 0.2266079 0.21875692 0.21164247 0.20525699 0.19930521 0.19389598 0.18892207 0.18429087 0.1800127 0.17593986 0.17221504] 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(25) 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(25) 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(25) 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 >>> rp.moffat_fit >>> print(rp.gaussian_fwhm) 11.009084813327853 >>> print(rp.moffat_fwhm) 10.868426520785151 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(25) 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' @cached_property 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-bin 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 @cached_property 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 @cached_property def _flux(self): """ The flux in each circular annulus (or aperture) as a 1D array. """ return np.diff(self._photometry[0]) @cached_property def _flux_err(self): """ The flux error in each circular annulus (or aperture) as a 1D array. """ return np.sqrt(np.diff(self._photometry[1] ** 2)) @cached_property 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]) @cached_property def _raw_profile(self): """ The raw (unnormalized) radial profile as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. """ # Ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._flux / self.area @cached_property def _raw_profile_error(self): """ The raw (unnormalized) radial profile errors as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. 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 @cached_property def _profile_nanmask(self): return np.isfinite(self._raw_profile) def _normalize_fit(self, raw_fit): """ Return a copy of a raw fit with its amplitude divided by the current profile normalization. """ fit = raw_fit.copy() # The amplitude has units if the input data has units amplitude = raw_fit.amplitude.quantity if amplitude is None: amplitude = raw_fit.amplitude.value fit.amplitude = amplitude / self.normalization_value return fit @cached_property def _raw_gaussian_fit(self): """ The 1D Gaussian fitted to the raw (unnormalized) radial profile, or `None` if the profile is entirely nonfinite or masked. """ profile = self._raw_profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] if len(profile) == 0: msg = ('The radial profile is entirely nonfinite 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, so 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 smallest ' 'radius with a finite profile value is larger than ' '0.3 times the fitted standard deviation.') warnings.warn(msg, AstropyUserWarning) return gaussian_fit @property def gaussian_fit(self): """ The fitted 1D Gaussian to the radial profile as a `~astropy.modeling.functional_models.Gaussian1D` model. The model amplitude reflects the current profile normalization (the Gaussian amplitude scales linearly with the profile values). The mean and standard deviation are independent of the normalization. The fit uses only the finite profile values, and the Gaussian mean is fixed at zero (the source center). Returns `None` if the profile is entirely nonfinite or masked. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the profile is entirely nonfinite or masked, if the sum of the profile is zero, or if the smallest bin center with a finite profile value is larger than 0.3 times the fitted standard deviation (e.g., the input radii do not extend close to the source center or the inner bins are masked). The fitter can also emit its own warning if the fit may not have converged. """ raw_fit = self._raw_gaussian_fit if raw_fit is None: return None return self._normalize_fit(raw_fit) @property def gaussian_profile(self): """ The fitted 1D Gaussian profile to the radial profile as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. The values reflect the current profile normalization. Returns `None` if the profile is entirely nonfinite or masked. """ if self._raw_gaussian_profile is None: return None return self._raw_gaussian_profile / self.normalization_value @cached_property def _raw_gaussian_profile(self): """ The raw-fit 1D Gaussian evaluated at the profile radii, or `None` if the profile is entirely nonfinite or masked. """ if self._raw_gaussian_fit is None: return None return self._raw_gaussian_fit(self.radius) @property def gaussian_fwhm(self): """ The full width at half maximum (FWHM) in pixels of the 1D Gaussian fitted to the radial profile. The FWHM is independent of the profile normalization. Returns `None` if the profile is entirely nonfinite or masked. """ if self._raw_gaussian_fit is None: return None return self._raw_gaussian_fit.stddev.value * gaussian_sigma_to_fwhm @cached_property def _raw_moffat_fit(self): """ The 1D Moffat fitted to the raw (unnormalized) radial profile, or `None` if the profile is entirely nonfinite or masked. """ profile = self._raw_profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] if len(profile) == 0: msg = ('The radial profile is entirely nonfinite 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, so 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) @property def moffat_fit(self): """ The fitted 1D Moffat to the radial profile as a `~astropy.modeling.functional_models.Moffat1D` model. The model amplitude reflects the current profile normalization (the Moffat amplitude scales linearly with the profile values). The core width and power index are independent of the normalization. The fit uses only the finite profile values. The Moffat ``x_0`` is fixed at zero (the source center) and ``alpha`` is bounded to the range [1, 25]. Returns `None` if the profile is entirely nonfinite or masked. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If the profile is entirely nonfinite or masked, or if the sum of the profile is zero. The fitter can also emit its own warning if the fit may not have converged. """ raw_fit = self._raw_moffat_fit if raw_fit is None: return None return self._normalize_fit(raw_fit) @property def moffat_profile(self): """ The fitted 1D Moffat profile to the radial profile as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. The values reflect the current profile normalization. Returns `None` if the profile is entirely nonfinite or masked. """ if self._raw_moffat_profile is None: return None return self._raw_moffat_profile / self.normalization_value @cached_property def _raw_moffat_profile(self): """ The raw-fit 1D Moffat evaluated at the profile radii, or `None` if the profile is entirely nonfinite or masked. """ if self._raw_moffat_fit is None: return None return self._raw_moffat_fit(self.radius) @property def moffat_fwhm(self): """ The full width at half maximum (FWHM) in pixels of the 1D Moffat fitted to the radial profile. The FWHM is independent of the profile normalization. Returns `None` if the profile is entirely nonfinite or masked. """ if self._raw_moffat_fit is None: return None return self._raw_moffat_fit.fwhm @cached_property def _data_profile(self): """ The raw (unnormalized) data profile returned as 1D arrays of radii (`~numpy.ndarray`) and data values (`~numpy.ndarray`, or `~astropy.units.Quantity` if the input ``data`` has units). Returns the radii and values of the unmasked data points between the minimum and maximum radii defined by the input radii. Pixels flagged in ``self.mask`` (including auto-masked nonfinite values) are excluded. """ shape = self.data.shape min_radius = np.min(self.radii) max_radius = np.max(self.radii) # The bounding box must include the pixels whose centers lie # exactly at max_radius, so the (exclusive) upper slice bounds # are floor(center + max_radius) + 1 x_min = int(max(np.floor(self.xycen[0] - max_radius), 0)) x_max = int(min(np.floor(self.xycen[0] + max_radius) + 1, shape[1])) y_min = int(max(np.floor(self.xycen[1] - max_radius), 0)) y_max = int(min(np.floor(self.xycen[1] + max_radius) + 1, 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 between the minimum and maximum radii defined by the # input radii radii = np.hypot(xidx - self.xycen[0], yidx - self.xycen[1]) within = (radii >= min_radius) & (radii <= max_radius) radii = radii[within] yidx_sub = yidx[within] xidx_sub = xidx[within] # Exclude masked pixels (user mask and auto-masked nonfinite # values) valid = ~self.mask[yidx_sub, xidx_sub] radii = radii[valid] data_values = self.data[yidx_sub[valid], xidx_sub[valid]] if self.unit is not None: data_values = data_values << self.unit return radii, data_values @cached_property def data_radius(self): """ The radial distances in pixels of the raw data points from the source center as a 1D `~numpy.ndarray`. All unmasked pixels whose centers lie between the minimum and maximum of the input ``radii``, inclusive, are included. """ return self._data_profile[0] @property def data_profile(self): """ The raw data values of the pixels at `data_radius` as a 1D `~numpy.ndarray`, or a `~astropy.units.Quantity` if the input ``data`` has units. The values reflect the current profile normalization. If `~photutils.profiles.ProfileBase.normalize` has been called, the raw data values are divided by the same normalization applied to ``profile``. """ return self._data_profile[1] / self.normalization_value astropy-photutils-24be529/photutils/profiles/tests/000077500000000000000000000000001526452531300226305ustar00rootroot00000000000000astropy-photutils-24be529/photutils/profiles/tests/__init__.py000066400000000000000000000000001526452531300247270ustar00rootroot00000000000000astropy-photutils-24be529/photutils/profiles/tests/conftest.py000066400000000000000000000014311526452531300250260ustar00rootroot00000000000000# 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-24be529/photutils/profiles/tests/test_core.py000066400000000000000000000055371526452531300252030ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the behavior shared by the profile classes. """ import astropy.units as u import numpy as np import pytest from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.profiles import (CurveOfGrowth, EllipticalCurveOfGrowth, EnsquaredCurveOfGrowth, RadialProfile) PROFILE_CASES = [ (RadialProfile, {}), (CurveOfGrowth, {}), (EnsquaredCurveOfGrowth, {}), (EllipticalCurveOfGrowth, {'axis_ratio': 0.5}), ] @pytest.mark.parametrize(('cls', 'kwargs'), PROFILE_CASES) class TestProfileInputs: def test_invalid_method(self, profile_data, cls, kwargs): """ Test that an invalid ``method`` or ``subpixels`` is rejected when the class is created. """ xycen, data, _, _ = profile_data radii = np.arange(1, 10) match = "Invalid method: 'invalid'" with pytest.raises(ValueError, match=match): cls(data, xycen, radii, method='invalid', **kwargs) match = 'subpixels must be a strictly positive integer' with pytest.raises(ValueError, match=match): cls(data, xycen, radii, method='subpixel', subpixels=0, **kwargs) # subpixels is used only by the 'subpixel' method cls(data, xycen, radii, method='center', subpixels=0, **kwargs) def test_invalid_data_shape(self, profile_data, cls, kwargs): """ Test that non-2D data is rejected when the class is created. """ xycen, data, _, _ = profile_data radii = np.arange(1, 10) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): cls(data[0], xycen, radii, **kwargs) def test_unit_mismatch(self, profile_data, cls, kwargs): """ Test that data and error must have the same units. """ xycen, data, error, _ = profile_data radii = np.arange(1, 10) match = 'must all have the same units' with pytest.raises(ValueError, match=match): cls(data << u.Jy, xycen, radii, error=error, **kwargs) @pytest.mark.parametrize('method', ['max', 'sum']) def test_normalize_negative(self, profile_data, cls, kwargs, method): """ Test that a profile with a negative maximum or sum is not normalized. """ xycen, data, _, _ = profile_data radii = np.arange(1, 10) profile = cls(-data, xycen, radii, **kwargs) expected = profile.profile.copy() assert np.all(expected < 0) match = 'The profile cannot be normalized' with pytest.warns(AstropyUserWarning, match=match): profile.normalize(method=method) assert profile.normalization_value == 1.0 assert_allclose(profile.profile, expected) astropy-photutils-24be529/photutils/profiles/tests/test_curve_of_growth.py000066400000000000000000001121101526452531300274370ustar00rootroot00000000000000# 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.modeling.models import Gaussian2D 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) 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__['_raw_profile'] = cg1.profile - np.nanmax(cg1.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) rr = cg1.calc_radius_at_ee(ee_vals) assert_allclose(cg1.calc_ee_at_radius(rr), ee_vals) 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 nonfinite 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__['_raw_profile'] = ecg1.profile - np.nanmax(ecg1.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) half_sizes_back = ecg1.calc_half_size_at_ee(ee_vals) assert_allclose(ecg1.calc_ee_at_half_size(half_sizes_back), ee_vals) 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 = 'half_sizes 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 = 'half_sizes 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) match = 'half_sizes must be a plain array of pixel values' half_sizes = np.arange(1, 10) << u.pix with pytest.raises(TypeError, 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 nonfinite 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__['_raw_profile'] = ecg1.profile - np.nanmax(ecg1.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) radii_back = ecg1.calc_radius_at_ee(ee_vals) assert_allclose(ecg1.calc_ee_at_radius(radii_back), ee_vals) 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 nonfinite 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, theta=0.7) r = repr(ecg) assert 'EllipticalCurveOfGrowth' in r assert f'xycen={xycen}' in r assert f'n_radii={len(radii)}' in r assert 'axis_ratio=0.5' in r assert 'theta=0.7' in r assert 'normalized=False' in r INTERP_CASES = [ (CurveOfGrowth, {}, 'calc_ee_at_radius', 'calc_radius_at_ee'), (EnsquaredCurveOfGrowth, {}, 'calc_ee_at_half_size', 'calc_half_size_at_ee'), (EllipticalCurveOfGrowth, {'axis_ratio': 0.5}, 'calc_ee_at_radius', 'calc_radius_at_ee'), ] @pytest.mark.parametrize(('cls', 'kwargs', 'ee_name', 'size_name'), INTERP_CASES) class TestNonMonotonicInterpolation: """ Tests of the encircled energy interpolation methods for profiles that are not monotonically increasing. """ @staticmethod def make_profile(profile_cls, kwargs, values, *, unit=None): """ Make a curve of growth whose raw profile is set to ``values``. """ sizes = np.arange(1, len(values) + 1, dtype=float) data = np.ones((51, 51)) if unit is not None: data = data << unit values = values << unit cog = profile_cls(data, (25, 25), sizes, **kwargs) cog.__dict__['_raw_profile'] = values return cog def test_noisy_profile(self, cls, kwargs, ee_name, size_name): """ Test that the two methods are consistent for a noisy profile. """ # A compact source, so that the noise dominates the outer # part of the profile xycen = (50.0, 40.0) yy, xx = np.mgrid[0:81, 0:101] data = Gaussian2D(21.0, *xycen, 4.0, 4.0)(xx, yy) rng = np.random.default_rng(0) data += rng.normal(0.0, 1.0, data.shape) sizes = np.arange(1, 36) cog = cls(data, xycen, sizes, **kwargs) cog.normalize() assert np.any(np.diff(cog.profile) < 0) calc_ee = getattr(cog, ee_name) calc_size = getattr(cog, size_name) # The interpolated curve passes through the measured profile, # so it also decreases where the profile does assert_allclose(calc_ee(sizes), cog.profile) fine_sizes = np.linspace(1, 35, 1000) assert np.any(np.diff(calc_ee(fine_sizes)) < 0) # Every value in the profile range has a size, the size never # decreases with the encircled energy, and the round-trip is # exact ee = np.linspace(cog.profile[0], 1.0, 200) result = calc_size(ee) assert np.all(np.isfinite(result)) assert np.all(np.diff(result) >= 0) assert_allclose(calc_ee(result), ee, atol=1e-12) # The maximum is reached at the size of the profile maximum assert_allclose(calc_size(1.0), sizes[np.argmax(cog.profile)]) # Values outside the profile range have no size outside = calc_size([cog.profile[0] - 0.01, 1.01]) assert np.all(np.isnan(outside)) def test_dip(self, cls, kwargs, ee_name, size_name): """ Test the values across a dip in the profile. """ values = np.array([1.0, 2.0, 3.0, 2.5, 2.8, 4.0, 5.0]) cog = self.make_profile(cls, kwargs, values) calc_ee = getattr(cog, ee_name) calc_size = getattr(cog, size_name) # The curve follows the profile through the dip assert_allclose(calc_ee(np.arange(1, 8)), values) assert 2.5 < calc_ee(3.5) < 3.0 # The smallest size that reaches the value is returned. The # values 2.6 and 2.8 are also reached again after the dip. assert_allclose(calc_size([1.0, 3.0, 4.0, 5.0]), [1, 3, 6, 7]) assert 2 < calc_size(2.6) < 3 assert 2 < calc_size(2.8) < 3 result = calc_size(3.5) assert 5 < result < 6 assert_allclose(calc_ee(result), 3.5) def test_decreasing_start(self, cls, kwargs, ee_name, size_name): """ Test a profile that does not increase at its smallest sizes. """ values = np.array([2.0, 2.0, 1.0, 3.0, 4.0]) cog = self.make_profile(cls, kwargs, values) calc_ee = getattr(cog, ee_name) calc_size = getattr(cog, size_name) assert_allclose(calc_ee([1.0, 2.0, 3.0]), [2.0, 2.0, 1.0]) assert_allclose(calc_size([2.0, 1.0, 4.0]), [1, 3, 5]) # A value below the first profile value is first reached where # the profile decreases result = calc_size(1.5) assert 2 < result < 3 assert_allclose(calc_ee(result), 1.5) result = calc_size(2.5) assert 3 < result < 4 assert_allclose(calc_ee(result), 2.5) assert np.all(np.isnan(calc_size([0.5, 4.5]))) def test_nonfinite(self, cls, kwargs, ee_name, size_name): """ Test that nonfinite profile values are skipped. """ values = np.array([1.0, 3.0, np.nan, 5.0, 7.0]) cog = self.make_profile(cls, kwargs, values) calc_ee = getattr(cog, ee_name) calc_size = getattr(cog, size_name) assert_allclose(calc_ee([2.0, 4.0]), [3.0, 5.0]) assert 2 < calc_ee(3.0) < 5 assert_allclose(calc_size([3.0, 5.0, 7.0]), [2, 4, 5]) assert np.isnan(calc_size(np.nan)) values = np.array([np.nan, np.nan, 1.0, np.nan]) cog = self.make_profile(cls, kwargs, values) match = 'must have at least two finite values' with pytest.raises(ValueError, match=match): getattr(cog, ee_name)(2.0) with pytest.raises(ValueError, match=match): getattr(cog, size_name)(1.0) def test_shapes(self, cls, kwargs, ee_name, size_name): """ Test that the output has the shape of the input. """ values = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) cog = self.make_profile(cls, kwargs, values) calc_ee = getattr(cog, ee_name) calc_size = getattr(cog, size_name) assert np.shape(calc_ee(2.5)) == () assert np.shape(calc_size(2.5)) == () ee = np.array([[1.5, 2.5], [3.5, 4.5]]) assert calc_size(ee).shape == (2, 2) assert_allclose(calc_size(ee), ee) assert_allclose(calc_ee(ee), ee) # A dimensionless quantity is accepted for a profile without # units assert_allclose(calc_size(2.5 * u.dimensionless_unscaled), 2.5) def test_units(self, cls, kwargs, ee_name, size_name): """ Test the interpolation methods for a profile with units. """ values = np.array([1.0, 2.0, 3.0, 2.5, 2.8, 4.0, 5.0]) unit = u.Jy cog = self.make_profile(cls, kwargs, values, unit=unit) calc_ee = getattr(cog, ee_name) calc_size = getattr(cog, size_name) ee = calc_ee([2.0, 4.0]) assert ee.unit == unit assert_allclose(ee.value, [2.0, 2.5]) # The sizes are in pixels and have no units result = calc_size([2.0, 4.0] * unit) assert not isinstance(result, u.Quantity) assert_allclose(result, [2, 6]) assert_allclose(calc_size([2000.0, 4000.0] * u.mJy), [2, 6]) # Values without units are in the profile units assert_allclose(calc_size([2.0, 4.0]), [2, 6]) match = 'not convertible' with pytest.raises(u.UnitConversionError, match=match): calc_size(2.0 * u.m) # The sizes are in pixels and cannot have units match = 'must be in pixels and cannot be a Quantity' with pytest.raises(TypeError, match=match): calc_ee(2.0 * u.pix) # A normalized profile is dimensionless cog.normalize() ee = calc_ee([2.0, 7.0]) assert ee.unit == u.dimensionless_unscaled assert_allclose(ee.value, [0.4, 1.0]) assert_allclose(calc_size([0.4, 1.0]), [2, 7]) assert_allclose(calc_size([0.4, 1.0] * u.dimensionless_unscaled), [2, 7]) assert_allclose(calc_size([40.0, 100.0] * u.percent), [2, 7]) astropy-photutils-24be529/photutils/profiles/tests/test_positional_kwargs.py000066400000000000000000000056631526452531300300120ustar00rootroot00000000000000# 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 photutils.profiles import CurveOfGrowth, RadialProfile from photutils.utils._optional_deps import HAS_MATPLOTLIB from photutils.utils.exceptions import PhotutilsDeprecationWarning @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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/profiles/tests/test_radial_profile.py000066400000000000000000000776021526452531300272310ustar00rootroot00000000000000# 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 nonfinite pixels should also be excluded xcen, ycen = xycen data2 = data.copy() data2[int(ycen), int(xcen)] = np.nan match = 'Input data contains nonfinite 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_data_boundary_pixels(self): """ Test that pixels whose centers lie exactly at the maximum radius are included in the data profile on all sides of the center. """ data = np.ones((101, 101)) xycen = (50.0, 50.0) max_radius = 25 rp = RadialProfile(data, xycen, np.arange(max_radius + 1)) yy, xx = np.mgrid[0:101, 0:101] dist = np.hypot(xx - xycen[0], yy - xycen[1]) assert len(rp.data_radius) == np.sum(dist <= max_radius) n_boundary = np.sum(rp.data_radius == max_radius) assert n_boundary == np.sum(dist == max_radius) def test_data_min_radius(self): """ Test that pixels whose centers lie inside the minimum radius are excluded from the data profile. """ data = np.ones((101, 101)) xycen = (50.0, 50.0) min_radius = 5 max_radius = 25 rp = RadialProfile(data, xycen, np.arange(min_radius, max_radius + 1)) yy, xx = np.mgrid[0:101, 0:101] dist = np.hypot(xx - xycen[0], yy - xycen[1]) within = (dist >= min_radius) & (dist <= max_radius) assert np.min(rp.data_radius) == min_radius assert len(rp.data_radius) == np.sum(within) assert rp.data_radius.shape == rp.data_profile.shape def test_data_profile_access_after_normalize(self, profile_data): """ Test that data_profile is consistent with the profile normalization when it is first accessed after normalize, and that unnormalize restores the raw values. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii) raw = rp1.data_profile.copy() normalization = np.max(rp1.profile) # Access data_profile for the first time after normalizing rp2 = RadialProfile(data, xycen, edge_radii) rp2.normalize() assert_allclose(rp2.data_profile, raw / normalization) # Unnormalizing must restore the raw values rp2.unnormalize() assert_allclose(rp2.data_profile, raw) # Repeated normalize calls must compound consistently rp2.normalize(method='max') rp2.normalize(method='sum') assert_allclose(rp2.data_profile, raw / rp2.normalization_value) rp2.unnormalize() assert_allclose(rp2.data_profile, raw) def test_data_profile_units(self, profile_data): """ Test that data_profile is a Quantity when the input data is a Quantity, and is dimensionless after normalization. """ xycen, data, error, _ = profile_data unit = u.Jy edge_radii = np.arange(36) rp = RadialProfile(data << unit, xycen, edge_radii, error=error << unit) assert rp.data_profile.unit == unit assert not isinstance(rp.data_radius, u.Quantity) rp.normalize() assert rp.profile.unit == u.dimensionless_unscaled assert rp.data_profile.unit == u.dimensionless_unscaled rp.unnormalize() assert rp.data_profile.unit == unit 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 = 'radii must be a plain array of pixel values' edge_radii = np.arange(10) << u.pix with pytest.raises(TypeError, 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 nonfinite 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 nonfinite 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 nonfinite 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.parametrize('name', ['gaussian', 'moffat']) def test_fit_units(self, profile_data, name): """ Test the Gaussian and Moffat fits with ``Quantity`` data. The model amplitude has the data unit and is dimensionless after the profile is normalized. """ xycen, data, error, _ = profile_data edge_radii = np.arange(36) unit = u.Jy rp1 = RadialProfile(data, xycen, edge_radii, error=error) rp2 = RadialProfile(data << unit, xycen, edge_radii, error=error << unit) fit1 = getattr(rp1, f'{name}_fit') fit2 = getattr(rp2, f'{name}_fit') assert fit2.amplitude.unit == unit assert_allclose(fit2.amplitude.value, fit1.amplitude.value) profile = getattr(rp2, f'{name}_profile') assert profile.unit == unit assert_allclose(fit2(rp2.radius), profile) rp1.normalize() rp2.normalize() fit1 = getattr(rp1, f'{name}_fit') fit2 = getattr(rp2, f'{name}_fit') assert fit2.amplitude.unit == u.dimensionless_unscaled assert_allclose(fit2.amplitude.value, fit1.amplitude.value) profile = getattr(rp2, f'{name}_profile') assert profile.unit == u.dimensionless_unscaled assert_allclose(fit2(rp2.radius), profile) rp2.unnormalize() assert getattr(rp2, f'{name}_fit').amplitude.unit == unit @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_ylabel_units(self, profile_data): """ Test that the plot y-axis label includes the data unit only when the profile has a physical unit. A normalized profile is dimensionless. """ xycen, data, error, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii) lines = rp1.plot() assert lines[0].axes.get_ylabel() == 'Radial Profile' unit = u.Jy rp2 = RadialProfile(data << unit, xycen, edge_radii, error=error << unit) lines = rp2.plot() assert lines[0].axes.get_ylabel() == 'Radial Profile (Jy)' rp2.normalize() lines = rp2.plot() assert lines[0].axes.get_ylabel() == 'Radial Profile' rp2.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 nonfinite 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 nonfinite 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_repeated_access(self, profile_data): """ Test that repeated access to ``moffat_fit``, ``moffat_profile``, and ``moffat_fwhm`` returns consistent values. The underlying fit is computed once and the returned values are derived from it. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) fit1 = rp.moffat_fit fit2 = rp.moffat_fit assert_equal(fit1.parameters, fit2.parameters) prof1 = rp.moffat_profile prof2 = rp.moffat_profile assert_equal(prof1, prof2) fwhm1 = rp.moffat_fwhm fwhm2 = rp.moffat_fwhm assert fwhm1 == fwhm2 def test_moffat_normalized(self, profile_data): """ Test that the Moffat fit reflects the profile normalization. """ 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 nonfinite data values. """ xycen, data, error, _ = profile_data data2 = data.copy() data2[40, 40] = np.nan edge_radii = np.arange(36) match = 'Input data contains nonfinite 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_updated_on_normalize(self, profile_data): """ Test that the Gaussian fit properties reflect the profile normalization after normalize is called. """ 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_updated_on_normalize(self, profile_data): """ Test that the Moffat fit properties reflect the profile normalization after normalize is called. """ 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, recomputed object 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_fits_updated_on_unnormalize(self, profile_data): """ Test that the Gaussian and Moffat fits match the original (unnormalized) profile after unnormalize is called. """ 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-24be529/photutils/profiles/tests/test_thread_safety.py000066400000000000000000000126161526452531300270710ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the profile classes are safe for concurrent use. All lazily-computed attributes cache raw (unnormalized) values that are immutable once computed, and the only mutable state is the scalar ``normalization_value`` attribute, which ``normalize`` and ``unnormalize`` update with a single atomic attribute store. Normalization-dependent attributes are derived on access by dividing the raw values by ``normalization_value``, so a concurrent reader sees values consistent with either the old or the new normalization, never a mixture within one returned array. """ import threading from concurrent.futures import ThreadPoolExecutor import numpy as np from numpy.testing import assert_equal from photutils.profiles import CurveOfGrowth, RadialProfile N_THREADS = 8 N_TOGGLES = 200 N_READS = 500 def test_concurrent_cold_access(profile_data): """ Test that concurrent first accesses of the lazy attributes on a single shared instance return results identical to a serial computation. """ xycen, data, error, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii, error=error) reference = RadialProfile(data, xycen, edge_radii, error=error) expected = (reference.profile, reference.profile_error, reference.area) barrier = threading.Barrier(N_THREADS) def task(): barrier.wait() return rp.profile, rp.profile_error, rp.area with ThreadPoolExecutor(max_workers=N_THREADS) as executor: futures = [executor.submit(task) for _ in range(N_THREADS)] for future in futures: for result, expect in zip(future.result(), expected, strict=True): assert_equal(result, expect) def test_concurrent_normalize_and_read(profile_data): """ Test that profile reads concurrent with normalize/unnormalize toggling always return one of the two consistent states, never a mixed state. """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) cog = CurveOfGrowth(data, xycen, radii) raw = cog.profile.copy() cog.normalize() normalized = cog.profile.copy() cog.unnormalize() barrier = threading.Barrier(N_THREADS) def writer(): barrier.wait() for _ in range(N_TOGGLES): cog.normalize() cog.unnormalize() def reader(): barrier.wait() return [cog.profile for _ in range(N_READS)] with ThreadPoolExecutor(max_workers=N_THREADS) as executor: writer_future = executor.submit(writer) reader_futures = [executor.submit(reader) for _ in range(N_THREADS - 1)] writer_future.result() for future in reader_futures: for snapshot in future.result(): assert (np.array_equal(snapshot, raw) or np.array_equal(snapshot, normalized)) def test_concurrent_normalize_methods(profile_data): """ Test that concurrent normalize calls with different methods leave the instance in one of the valid states, with the profile consistent with the final normalization value. """ xycen, data, _, _ = profile_data radii = np.arange(1, 36) cog = CurveOfGrowth(data, xycen, radii) raw = cog.profile.copy() expected_values = set() for method in ('max', 'sum'): twin = CurveOfGrowth(data, xycen, radii) twin.normalize(method=method) expected_values.add(twin.normalization_value) barrier = threading.Barrier(N_THREADS) def task(method): barrier.wait() for _ in range(N_TOGGLES): cog.normalize(method=method) methods = ['max' if i % 2 == 0 else 'sum' for i in range(N_THREADS)] with ThreadPoolExecutor(max_workers=N_THREADS) as executor: futures = [executor.submit(task, method) for method in methods] for future in futures: future.result() assert cog.normalization_value in expected_values assert_equal(cog.profile, raw / cog.normalization_value) def test_concurrent_fit_access_during_normalize(profile_data): """ Test that the fit properties read concurrently with normalize/unnormalize toggling are always consistent with one of the two normalization states, and that the FWHM is unaffected. """ xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp = RadialProfile(data, xycen, edge_radii) raw_amplitude = rp.gaussian_fit.amplitude.value raw_fwhm = rp.gaussian_fwhm rp.normalize() norm_amplitude = rp.gaussian_fit.amplitude.value rp.unnormalize() n_workers = 4 barrier = threading.Barrier(n_workers) def writer(): barrier.wait() for _ in range(N_TOGGLES): rp.normalize() rp.unnormalize() def reader(): barrier.wait() return [(rp.gaussian_fit.amplitude.value, rp.gaussian_fwhm) for _ in range(100)] with ThreadPoolExecutor(max_workers=n_workers) as executor: writer_future = executor.submit(writer) reader_futures = [executor.submit(reader) for _ in range(n_workers - 1)] writer_future.result() for future in reader_futures: for amplitude, fwhm in future.result(): assert amplitude in (raw_amplitude, norm_amplitude) assert fwhm == raw_fwhm astropy-photutils-24be529/photutils/psf/000077500000000000000000000000001526452531300204335ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf/__init__.py000066400000000000000000000025751526452531300225550ustar00rootroot00000000000000# 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 def __getattr__(name): # EPSFBuildResult was renamed to EPSFBuildResults in 3.1. if name == 'EPSFBuildResult': import warnings from photutils.utils.exceptions import PhotutilsDeprecationWarning from .epsf_builder import EPSFBuildResults msg = ('EPSFBuildResult is deprecated and will be removed in ' 'version 4.0. Use EPSFBuildResults instead.') warnings.warn(msg, PhotutilsDeprecationWarning, stacklevel=2) return EPSFBuildResults msg = f'module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) astropy-photutils-24be529/photutils/psf/_bispline.pyx000066400000000000000000000337031526452531300231470ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Cython kernels that evaluate the bivariate B-splines of the PSF image models at scattered points. The splines are the ones built by `scipy.interpolate.RectBivariateSpline`, passed in as their FITPACK knot vectors and coefficient arrays. The PSF image models build bicubic interpolating splines (``kx=ky=3``, ``s=0``), and the kernels evaluate only bicubic splines. The evaluation follows the FITPACK ``fpbisp`` and ``fpbspl`` routines that ``RectBivariateSpline.__call__`` uses. The coordinates are clamped to the knot range (constant extrapolation), the knot interval is located, and the nonzero B-spline basis functions are computed with the Cox-de Boor recursion. The partial derivatives are computed from the derivative of the basis functions in the same pass, which is mathematically identical to evaluating the derivative splines that ``RectBivariateSpline.partial_derivative`` builds. The one difference is beyond the knot range, where the clamped spline is constant along the clamped axis. The partial derivative along that axis is zero there, while the derivative splines give the derivative at the edge. The kernels combine the splines of the (up to four) bounding grid PSFs of a gridded PSF model with their bilinear weights in a single call, instead of one spline call per grid PSF and per derivative. A single image PSF is the case of one spline with unit weight. For the small fit regions used in PSF fitting the spline call overhead dominates the evaluation, so this reduces the model evaluation cost by several times. The kernels run without the GIL and use no global mutable state, so this module is safe to use from multiple threads, including on free-threaded Python builds. """ __all__ = ['bispline_sum', 'bispline_sum_deriv'] cdef enum: # The degree of the splines along both axes DEGREE = 3 cdef inline Py_ssize_t _find_interval(const double[::1] t, double x) noexcept nogil: """ Return the index ``l`` of the knot interval ``t[l] <= x < t[l + 1]`` for a coordinate already clamped to the knot range. The result is limited to the valid intervals ``k <= l <= n - k - 2`` for the spline degree ``k``, which matches the FITPACK search. """ cdef Py_ssize_t n = t.shape[0] cdef Py_ssize_t lo = DEGREE cdef Py_ssize_t hi = n - DEGREE - 1 # exclusive upper bound of l + 1 cdef Py_ssize_t mid # Binary search for the last knot <= x within t[k:n-k-1]. The # upper bound keeps the result at or below n - k - 2. while hi - lo > 1: mid = (lo + hi) // 2 if t[mid] <= x: lo = mid else: hi = mid return lo cdef inline void _raise_degree(const double[::1] t, Py_ssize_t l, double x, int j, double *h) noexcept nogil: """ Replace the ``j`` nonzero B-spline basis functions of degree ``j - 1`` on the knot interval ``l`` at ``x``, in ``h``, with the ``j + 1`` basis functions of degree ``j``. This is one step of the FITPACK ``fpbspl`` recursion. """ cdef double hh[DEGREE] cdef double f, denom cdef int i cdef Py_ssize_t li, lj for i in range(j): hh[i] = h[i] h[0] = 0.0 for i in range(j): li = l + i + 1 lj = li - j denom = t[li] - t[lj] if denom == 0.0: h[i + 1] = 0.0 else: f = hh[i] / denom h[i] += f * (t[li] - x) h[i + 1] = f * (x - t[lj]) cdef inline void _basis(const double[::1] t, Py_ssize_t l, double x, double *h) noexcept nogil: """ Compute the four nonzero cubic B-spline basis functions on the knot interval ``l`` at ``x``. ``h[i]`` is the basis function ``B[l - 3 + i, 3]``. """ cdef int j h[0] = 1.0 for j in range(1, DEGREE + 1): _raise_degree(t, l, x, j, h) cdef inline void _basis_deriv(const double[::1] t, Py_ssize_t l, double x, double *h, double *dh) noexcept nogil: """ Compute the four nonzero cubic B-spline basis functions on the knot interval ``l`` at ``x`` (``h``) and their first derivatives (``dh``). """ cdef double f, denom cdef int i, j cdef Py_ssize_t li, lj h[0] = 1.0 for j in range(1, DEGREE): _raise_degree(t, l, x, j, h) # The quadratic basis functions give the derivatives of the cubic # basis functions for i in range(DEGREE + 1): dh[i] = 0.0 for i in range(DEGREE): li = l + i + 1 lj = li - DEGREE denom = t[li] - t[lj] if denom != 0.0: f = DEGREE * h[i] / denom dh[i] -= f dh[i + 1] += f _raise_degree(t, l, x, DEGREE, h) cdef inline double _clamp(const double[::1] t, double x) noexcept nogil: cdef Py_ssize_t n = t.shape[0] if x < t[DEGREE]: return t[DEGREE] if x > t[n - DEGREE - 1]: return t[n - DEGREE - 1] return x def bispline_sum(const double[::1] tx, const double[::1] ty, const double[:, ::1] coeffs, const Py_ssize_t[::1] grid_idx, const double[::1] weights, const double[::1] x, const double[::1] y, double[::1] out): """ Evaluate a weighted sum of bicubic B-splines at scattered points. Parameters ---------- tx, ty : 1D ndarray of float64 (C-contiguous) The knot vectors along the first (x) and second (y) variables of bicubic splines (``kx=ky=3``), in the FITPACK format of ``RectBivariateSpline.get_knots()``. The knots must be in nondecreasing order, which is not checked. The knot interval is located with a binary search, which gives wrong values for unordered knots. A scipy smoothing fit (``s > 0``) can return such knots. coeffs : 2D ndarray of float64 (C-contiguous) The coefficient arrays, one row per spline, each in the FITPACK layout (x-major) of ``RectBivariateSpline.get_coeffs()``. grid_idx : 1D ndarray of intp (C-contiguous) The rows of ``coeffs`` to combine. weights : 1D ndarray of float64 (C-contiguous) The weight of each combined spline. Splines with a zero weight are skipped. x, y : 1D ndarray of float64 (C-contiguous) The coordinates of the evaluation points. out : 1D ndarray of float64 (C-contiguous) Output. Filled with ``sum_g weights[g] * S_g(x, y)``, where ``S_g`` is the spline of row ``grid_idx[g]``. """ cdef Py_ssize_t n_points = x.shape[0] cdef Py_ssize_t n_splines = grid_idx.shape[0] cdef Py_ssize_t nky1 = ty.shape[0] - DEGREE - 1 cdef Py_ssize_t p, g, lx, ly, row, base cdef int i, j cdef double xp, yp, val, s, sy, w cdef double hx[DEGREE + 1] cdef double hy[DEGREE + 1] # The kernels index the arrays without bounds checks, so the checks # below must cover every index that the loops compute. The grid_idx # check comes first because placing it after the other checks makes # the compiler generate a 60% slower evaluation loop. for g in range(n_splines): if grid_idx[g] < 0 or grid_idx[g] >= coeffs.shape[0]: msg = 'grid_idx has an index that is not a row of coeffs' raise ValueError(msg) if (y.shape[0] != n_points or out.shape[0] != n_points or weights.shape[0] != n_splines): msg = 'x, y, out, grid_idx, and weights have inconsistent lengths' raise ValueError(msg) if tx.shape[0] < 2 * (DEGREE + 1) or ty.shape[0] < 2 * (DEGREE + 1): msg = 'the knot vectors are too short for bicubic splines' raise ValueError(msg) if coeffs.shape[1] != (tx.shape[0] - DEGREE - 1) * nky1: msg = 'coeffs does not match the knot vectors' raise ValueError(msg) with nogil: for p in range(n_points): xp = _clamp(tx, x[p]) yp = _clamp(ty, y[p]) lx = _find_interval(tx, xp) ly = _find_interval(ty, yp) _basis(tx, lx, xp, hx) _basis(ty, ly, yp, hy) val = 0.0 for g in range(n_splines): w = weights[g] if w == 0.0: continue row = grid_idx[g] s = 0.0 for i in range(DEGREE + 1): base = (lx - DEGREE + i) * nky1 + ly - DEGREE sy = 0.0 for j in range(DEGREE + 1): sy += coeffs[row, base + j] * hy[j] s += hx[i] * sy val += w * s out[p] = val def bispline_sum_deriv(const double[::1] tx, const double[::1] ty, const double[:, ::1] coeffs, const Py_ssize_t[::1] grid_idx, const double[::1] weights, const double[::1] dw_dx, const double[::1] dw_dy, double scale_x, double scale_y, const double[::1] x, const double[::1] y, double[::1] out, double[::1] out_dx, double[::1] out_dy): """ Evaluate a weighted sum of bicubic B-splines and its derivatives with respect to a shift of the spline coordinates and to the weights, at scattered points. This is the ``fit_deriv`` kernel of the PSF image models. With ``S_g`` the spline of row ``grid_idx[g]``, the outputs are * ``out = sum_g weights[g] * S_g`` * ``out_dx = sum_g dw_dx[g] * S_g - scale_x * weights[g] * dS_g/dx`` * ``out_dy = sum_g dw_dy[g] * S_g - scale_y * weights[g] * dS_g/dy`` where ``scale_x`` and ``scale_y`` are the positive factors that convert a model position shift to a shift of the spline coordinates (the oversampling). A shift of the model position moves the spline coordinates the opposite way, which is the minus sign in the formulas. The coordinates are clamped to the knot range, so ``dS_g/dx`` is zero for a point beyond the knot range along x, and likewise for ``dS_g/dy`` along y. Parameters ---------- tx, ty, coeffs, grid_idx, weights See `bispline_sum`. dw_dx, dw_dy : 1D ndarray of float64 (C-contiguous) The derivatives of the weights with respect to the model x and y positions. A spline is skipped only if its weight and both weight derivatives are zero. scale_x, scale_y : float The spline coordinate shift per unit model position shift. x, y See `bispline_sum`. out, out_dx, out_dy : 1D ndarray of float64 (C-contiguous) Output. The weighted sum and its two derivatives. """ cdef Py_ssize_t n_points = x.shape[0] cdef Py_ssize_t n_splines = grid_idx.shape[0] cdef Py_ssize_t nky1 = ty.shape[0] - DEGREE - 1 cdef Py_ssize_t p, g, lx, ly, row, base cdef int i, j cdef double xp, yp, w, wx, wy, c cdef double s, s_dx, s_dy, sy, sy_dy, val, val_dx, val_dy cdef double hx[DEGREE + 1] cdef double hy[DEGREE + 1] cdef double dhx[DEGREE + 1] cdef double dhy[DEGREE + 1] # The kernels index the arrays without bounds checks, so the checks # below must cover every index that the loops compute. The grid_idx # check comes first because placing it after the other checks makes # the compiler generate a 60% slower evaluation loop. for g in range(n_splines): if grid_idx[g] < 0 or grid_idx[g] >= coeffs.shape[0]: msg = 'grid_idx has an index that is not a row of coeffs' raise ValueError(msg) if (y.shape[0] != n_points or out.shape[0] != n_points or out_dx.shape[0] != n_points or out_dy.shape[0] != n_points or weights.shape[0] != n_splines or dw_dx.shape[0] != n_splines or dw_dy.shape[0] != n_splines): msg = ('x, y, the outputs, grid_idx, weights, dw_dx, and dw_dy ' 'have inconsistent lengths') raise ValueError(msg) if tx.shape[0] < 2 * (DEGREE + 1) or ty.shape[0] < 2 * (DEGREE + 1): msg = 'the knot vectors are too short for bicubic splines' raise ValueError(msg) if coeffs.shape[1] != (tx.shape[0] - DEGREE - 1) * nky1: msg = 'coeffs does not match the knot vectors' raise ValueError(msg) with nogil: for p in range(n_points): xp = _clamp(tx, x[p]) yp = _clamp(ty, y[p]) lx = _find_interval(tx, xp) ly = _find_interval(ty, yp) _basis_deriv(tx, lx, xp, hx, dhx) _basis_deriv(ty, ly, yp, hy, dhy) # A clamped coordinate does not change with the input # coordinate, so the spline is constant along that axis if x[p] < xp or x[p] > xp: for i in range(DEGREE + 1): dhx[i] = 0.0 if y[p] < yp or y[p] > yp: for j in range(DEGREE + 1): dhy[j] = 0.0 val = 0.0 val_dx = 0.0 val_dy = 0.0 for g in range(n_splines): w = weights[g] wx = dw_dx[g] wy = dw_dy[g] if w == 0.0 and wx == 0.0 and wy == 0.0: continue row = grid_idx[g] s = 0.0 s_dx = 0.0 s_dy = 0.0 for i in range(DEGREE + 1): base = (lx - DEGREE + i) * nky1 + ly - DEGREE sy = 0.0 sy_dy = 0.0 for j in range(DEGREE + 1): c = coeffs[row, base + j] sy += c * hy[j] sy_dy += c * dhy[j] s += hx[i] * sy s_dx += dhx[i] * sy s_dy += hx[i] * sy_dy val += w * s val_dx += wx * s - scale_x * w * s_dx val_dy += wy * s - scale_y * w * s_dy out[p] = val out_dx[p] = val_dx out_dy[p] = val_dy astropy-photutils-24be529/photutils/psf/_bispline_inputs.py000066400000000000000000000011141526452531300243500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Constant inputs of the compiled spline kernels for a single spline. A model with one spline calls the kernels with one coefficient row that has unit weight and no weight derivatives. """ import numpy as np __all__ = ['ONE_PLANE', 'UNIT_WEIGHT', 'ZERO_WEIGHT'] ONE_PLANE = np.zeros(1, dtype=np.intp) UNIT_WEIGHT = np.ones(1) ZERO_WEIGHT = np.zeros(1) # Every model shares these arrays, so they must never be modified ONE_PLANE.setflags(write=False) UNIT_WEIGHT.setflags(write=False) ZERO_WEIGHT.setflags(write=False) astropy-photutils-24be529/photutils/psf/_components.py000066400000000000000000002066641526452531300233470ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Private implementation classes for PSF photometry components. This is a private module. Its classes and functions are implementation details of the PSF photometry classes and are not intended for direct public use. """ import contextlib import warnings from copy import deepcopy 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 from astropy.table import QTable, Table, hstack, join from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import AperturePhotometry, CircularAperture from photutils.datasets import make_model_image as _make_model_image from photutils.utils._deprecation import DeprecatedColumnQTable from photutils.utils._flags import update_flag_docstring from photutils.utils._misc import _get_meta from photutils.utils._overlap import overlap_slices 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 or None The bound offset (parameter will be bounded to [param_value - bound_value, param_value + bound_value]). If None, no bounds are applied. """ if bound_value is not None: param_obj = getattr(model, param_name) param_obj.bounds = (param_value - bound_value, param_value + bound_value) def _update_flags_docstring(func): """ Decorator to insert the PSF flag descriptions into a docstring. The ```` placeholder in the function docstring is replaced with a bullet list generated from ``PSF_FLAGS``. Parameters ---------- func : function The function to decorate. Returns ------- func : function The decorated function with an updated docstring. """ return update_flag_docstring(func, PSF_FLAGS, indent=None) 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_0, y_0_0, flux_0, x_0_1, y_0_1, flux_1, etc., which is each base parameter name followed by the source index) 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. Each flat parameter copies the default value, fixed status, and bounds from the base PSF model parameter. Tied constraints are not propagated to grouped fits. 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, fixed status, and bounds from the # base PSF model. Note that tied constraints are not # propagated to grouped fits. 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, bounds=base_param_obj.bounds) class_attrs[flat_param_name] = param # Add methods def model_init(self, **kwargs): super(type(self), self).__init__(**kwargs) self.n_sources = n_sources # All instances share this single model copy. This is safe # only because evaluate() is stateless. 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 : 2-element sequence of float or None, 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. An element of `None` means no bound along that axis. 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 def _get_flat_model(sources, psf_model, param_mapper, *, xy_bounds=None, class_cache=None): """ Get or create a flat model for a group of sources. The dynamically-generated flat model classes can be cached to avoid recreating them for groups with the same number of sources, improving performance when processing many groups. A cached class captures a copy of ``psf_model`` (including its parameter values, fixed flags, and bounds), so a given cache dict is valid only for a single, fixed ``psf_model``. The caller owns the cache dict and must not share it between different PSF models. 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 : 2-element sequence of float or None, or None, optional Bounds for x and y position parameters as (x_bound, y_bound). An element of `None` means no bound along that axis. class_cache : dict or None, optional Cache of flat model classes keyed on the number of sources. If `None`, a new class is always created. 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) if class_cache is None: model_class = _create_flat_model_class(n_sources, psf_model) else: model_class = class_cache.get(n_sources) if model_class is None: model_class = _create_flat_model_class(n_sources, psf_model) class_cache[n_sources] = model_class # 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 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, used in error messages (e.g., 'data', 'error', 'mask'). If 'mask', `numpy.ma.nomask` is converted to None and the array is converted to bool. 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) if name == 'mask': # Coerce to bool so integer masks (e.g., 0/1 DQ # arrays) cannot be misused as fancy indices array = array.astype(bool) 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] # Convert a plain Column carrying a unit attribute (e.g., from # a Table instead of a QTable) to a Quantity if (not isinstance(values, u.Quantity) and getattr(values, 'unit', None) is not None): values = u.Quantity(values) 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 = (f'The input data has units, but the init_params ' f'{colname} column does not. It must be a Quantity ' 'column (e.g., use a QTable).') 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, or if the units of the flux or local_bkg column are not consistent with the data units (only one of them has units, or the units are incompatible). """ 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: # Nonfinite 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, *, local_bkg=None): """ 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. local_bkg : `~numpy.ndarray` or None, optional The per-pixel local background level of each source. If input, each finite value times the unmasked aperture area within the image is subtracted from the aperture flux. 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) phot = AperturePhotometry(data, apertures, mask=mask) flux = phot.flux if local_bkg is not None: bkg_flux = local_bkg * phot.area.value finite_mask = np.isfinite(bkg_flux) flux = flux.copy() flux[finite_mask] -= bkg_flux[finite_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 or len(sources) == 0: 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) # Only finite local_bkg values are subtracted local_bkg = np.asarray(init_params['local_bkg'].value) flux = self.get_aper_fluxes(data, mask, init_params, local_bkg=local_bkg) if self.data_unit is not None: flux <<= self.data_unit 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 : `~numpy.ndarray` Array of shape ``(2, ny, nx)``, where ``(ny, nx)`` is ``fit_shape``, that unpacks into the (y_offsets, x_offsets) arrays of coordinate offsets from the origin. """ # fit_shape is fixed at construction, so compute only once if self._cached_offsets is None: ny, nx = self.fit_shape self._cached_offsets = np.mgrid[0:ny, 0:nx] 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 nonfinite positions if not (np.isfinite(x_cen) and np.isfinite(y_cen)): return True, 'invalid_position' # Check for nonfinite flux if not np.isfinite(flux_init): return True, 'non_finite_flux' # Sources 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 ('no_overlap' or 'fully_masked') - 'xx' : 1D array or None, x pixel indices of the unmasked cutout pixels - 'yy' : 1D array or None, y pixel indices of the unmasked cutout pixels - 'cutout' : 1D array or None, data values of the unmasked cutout pixels, with the local background subtracted if it is finite - 'n_pixels' : int, number of unmasked pixels in the cutout - 'cen_index' : int or nan, index in the 1D arrays of the pixel containing the source center, or nan if that pixel is masked or outside the image If the cutout is not valid, 'xx', 'yy', and 'cutout' are None, 'n_pixels' is 0, and 'cen_index' is nan. """ 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, 'n_pixels': 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, 'n_pixels': 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. The subtraction # is not done in place so that integer-dtype data works # with a float local_bkg (the fancy index above already # returned a copy). if np.isfinite(local_bkg_value): cutout = 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, 'n_pixels': 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 or None, optional Maximum number of fitting iterations. Default is 100. If None, the default of the fitter is used. xy_bounds : 2-element sequence of float or None, or None, optional Bounds for x and y position parameters as (x_bound, y_bound). An element of `None` means no bound along that axis. If provided, fitting positions will be constrained to within these bounds of the initial values. """ def __init__(self, psf_model, param_mapper, *, fitter=None, fitter_maxiters=100, xy_bounds=None): 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 # Cache of flat model classes keyed on the number of sources. # The cache is per-instance because the cached classes capture # a copy of psf_model. self._flat_model_classes = {} 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_0, y_0_0, flux_0, x_0_1, y_0_1, flux_1, etc., which is each base parameter name followed by the source index) 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 per PSFFitter instance based on the number of sources to improve performance for repeated group sizes. Parameters ---------- sources : `~astropy.table.Table` or list of `~astropy.table.Row` 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, class_cache=self._flat_model_classes) 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) _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) _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 the error array contains nonpositive or nonfinite values at the fit pixels. """ kwargs = {'inplace': True} 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 nonpositive or nonfinite ' 'values within the fit region centered near ' f'x={np.mean(xi):.1f}, y={np.mean(yi):.1f}. ' 'Cannot compute fit weights.') raise ValueError(msg) weights = 1.0 / err # 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(group_cov, n_sources, n_fit_params): """ Extract individual source covariance matrices from group covariance. The group covariance matrix is assumed to be ordered by source, with ``n_fit_params`` consecutive rows and columns for each source. Only the diagonal block of each source is extracted. The covariances between different sources are not included. Parameters ---------- group_cov : `~numpy.ndarray` 2D covariance matrix for the entire group of sources. n_sources : int Number of sources in the group. n_fit_params : 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(n_sources): start = i * n_fit_params end = (i + 1) * n_fit_params 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 = [] # Fixed parameters are copied as well because their per-source # values are stored only in the flat model param_names = self.psf_model.param_names for i in range(n_sources): # Create a copy of the base PSF model source_model = self.psf_model.copy() # Extract parameter values and bounds 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): flat_param = getattr(flat_model, flat_param_name) setattr(source_model, param_name, flat_param.value) getattr(source_model, param_name).bounds = flat_param.bounds source_models.append(source_model) return source_models class PSFResultsAssembler: """ Helper class to handle 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. """ def __init__(self, param_mapper, fit_shape): self.param_mapper = param_mapper self.fit_shape = fit_shape 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. """ # 1, 2, 3, and 4 are the "good" convergence values for both # the leastsq and least_squares optimizers. None means the # key was not present in the fit info. bad = [idx for idx, info in enumerate(fit_info) if any(info.get(key) not in (None, 1, 2, 3, 4) for key in ('ierr', 'status'))] return np.array(bad, dtype=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 nonfitted (fixed) parameters. """ # Create error columns for model 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 nonfitted (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, applied to the flux error column. 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 the qfit and cfit fit quality metrics. The input ``reduced_chi2`` values are returned unchanged. 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 : array-like The input reduced chi-squared values, unchanged. """ 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 n_sources = len(sum_abs_residuals) qfit = np.full(n_sources, np.nan, dtype=float) cfit = np.full(n_sources, 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]) # An exact fit has zero residuals, and the sign of the resulting # zero depends on the sign convention of the fitter residuals # (cfit) and on the sign of the flux (qfit and cfit). Adding # zero turns a negative zero (-0.0) into a positive zero and # leaves all other values, including NaN, unchanged. qfit += 0.0 cfit += 0.0 return qfit, cfit, reduced_chi2 @_update_flags_docstring def define_flags(self, results_tbl, shape, fit_error_indices, fit_info, fitted_models_table, valid_mask, invalid_reasons, init_params): # numpydoc ignore: RT05 """ 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. Only valid sources are checked for parameters near a bound. If `None`, all sources are valid. invalid_reasons : list or None List of reasons why sources were invalid. init_params : `~astropy.table.QTable` Initial parameter guesses for sources, containing the initial positions and local_bkg. Returns ------- flags : `~numpy.ndarray` Array of integer flags where each bit indicates a specific condition. The flags are: """ 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'] # 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 # 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.POSITION_OUTSIDE_IMAGE # Nonpositive flux flag4_mask = results_tbl[flux_col] <= 0 flags[flag4_mask] |= PSF_FLAGS.NON_POSITIVE_FLUX # Possible nonconvergence if fit_error_indices is not None: flags[fit_error_indices] |= PSF_FLAGS.NO_CONVERGENCE # 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 # Near a fitted-parameter bound. Bounds may come from # the xy_bounds keyword or be set directly on the PSF model. bound_tol = 0.01 param_names = [col for col in fitted_models_table.colnames if col != 'id' and not col.endswith(('_fixed', '_bounds'))] if valid_mask is None: valid_indices = np.arange(len(results_tbl)) else: valid_indices = np.flatnonzero(valid_mask) for index in valid_indices: row = fitted_models_table[index] for param in param_names: if row[f'{param}_fixed']: continue bounds = np.array([i for i in row[f'{param}_bounds'] if i is not None]) if bounds.size == 0: continue value = row[param] if isinstance(value, u.Quantity): value = value.value if np.any(np.abs(bounds - value) <= bound_tol): flags[index] |= PSF_FLAGS.PARAMETER_NEAR_BOUND break # Invalid source reasons. Sources invalid because of a # nonfinite input flux get the nonfinite flux flag below from # their NaN fitted flux. 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 # Nonfinite fitted position non_finite_pos_mask = ~np.isfinite(x_fit) | ~np.isfinite(y_fit) flags[non_finite_pos_mask] |= PSF_FLAGS.NON_FINITE_POSITION # Nonfinite fitted 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 # Nonfinite 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_LOCAL_BKG # Flag=4096: fitted position outside the fitted data region. # The region is the fit_shape box around the initial position, # trimmed to the image, matching the overlap_slices cutout in # get_source_cutout_data. Its edges are the outer pixel edges. # Nonfinite positions compare as False and are not flagged. fit_ny, fit_nx = self.fit_shape x_init = np.asarray( init_params[self.param_mapper.init_colnames['x']], dtype=float) y_init = np.asarray( init_params[self.param_mapper.init_colnames['y']], dtype=float) x_start = np.ceil(x_init - fit_nx / 2.0) y_start = np.ceil(y_init - fit_ny / 2.0) x_min = np.maximum(x_start, 0) - 0.5 y_min = np.maximum(y_start, 0) - 0.5 x_max = np.minimum(x_start + fit_nx, nx) - 0.5 y_max = np.minimum(y_start + fit_ny, ny) - 0.5 flag4096_mask = ((x_fit < x_min) | (x_fit > x_max) | (y_fit < y_min) | (y_fit > y_max)) flags[flag4096_mask] |= PSF_FLAGS.POSITION_OUTSIDE_FIT_REGION 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, *, warn_no_convergence=True): """ 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``, ``reduced_chi2``, 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, reduced_chi2). define_flags_func : callable Function to define per-source bitwise flags. class_name : str Name of the calling class, stored in the ``'psf_class'`` entry of the table metadata. metadata_attrs : dict Dictionary of metadata attributes to add to the table. warn_no_convergence : bool, optional Whether to emit a warning if one or more fits may not have converged. 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, reduced_chi2) - Bitwise flags indicating fit conditions - Fit 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. # State keys consumed here, each removed after use to reduce # memory usage: # - 'n_pixels_fit', 'group_size': added as columns # - 'sum_abs_residuals', 'cen_residuals', 'reduced_chi2': # consumed by calc_fit_metrics_func # - 'fit_error_indices', 'fitted_models_table', # 'valid_mask_by_id': consumed by define_flags_func # 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 (warn_no_convergence and 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 : int, 2-tuple of int, or `None`, 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. Nonfinite 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. If the fit was performed on data with units, the image will be a `~astropy.units.Quantity` with the same units. Raises ------ ValueError If no results are available because the instance has not been run or no sources were found. """ 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`, `~astropy.units.Quantity`, or \ `~astropy.nddata.NDData` The 2D array on which photometry was performed. This should be the same array input when calling the PSF-photometry class. psf_shape : int, 2-tuple of int, or `None`, 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. Nonfinite local background values (NaN or inf) are not subtracted from the residual image. Returns ------- array : 2D `~numpy.ndarray`, `~astropy.units.Quantity`, or \ `~astropy.nddata.NDData` The residual image of the ``data`` minus the fit PSF models minus the optional ``local_bkg``. The output has the same type as the input ``data``. If ``data`` is an `~astropy.nddata.NDData` object, a copy is returned with the residual image as its data. Raises ------ ValueError If no results are available because the instance has not been run or no sources were found. """ 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 : `~astropy.table.Column` or `~astropy.units.Quantity`, \ 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 nonfinite values to 0 to avoid # corrupting the model image model_params = model_params.copy() if local_bkgs is None: local_bkgs_clean = np.zeros(len(model_params)) else: local_bkgs_clean = local_bkgs.copy() # Replace nonfinite 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-24be529/photutils/psf/epsf_builder.py000066400000000000000000004641641526452531300234670ustar00rootroot00000000000000# 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 numbers import warnings from dataclasses import dataclass, field from typing import NamedTuple import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata import NoOverlapError, PartialOverlapError from astropy.stats import SigmaClip from astropy.table import Table from astropy.utils.decorators import deprecated, deprecated_attribute from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import convolve from photutils.centroids import centroid_com, centroid_symmetry 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._overlap import overlap_slices 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = ['EPSFBuildResults', 'EPSFBuilder', 'EPSFFitter'] SIGMA_CLIP = SigmaClipSentinelDefault(sigma=3.0, maxiters=10) # Parameters of the automatic ('auto') smoothing kernel and fit shape. # The smoothing window is this fraction of the ePSF FWHM (in oversampled # grid points) and no smoothing is applied below the minimum size. The # fit shape is this multiple of the ePSF FWHM (in detector pixels), with # the given minimum size. _AUTO_KERNEL_FWHM_FRACTION = 0.7 _AUTO_KERNEL_MIN_SIZE = 5 _AUTO_FIT_FWHM_FRACTION = 2.0 _AUTO_FIT_MIN_SIZE = 5 # Smoothing of the wings of the final ePSF, in units of the ePSF FWHM. # Beyond _WING_START the ePSF is blended, over _WING_BLEND, into a # least-squares polynomial fit of degree _WING_DEGREE in a box of width # _WING_SMALL_BOX. Beyond _WING_LARGE_START it is blended into the fit # in a box of width _WING_LARGE_BOX. This follows the approach of # Anderson 2016 (WFC3 ISR 2016-12), which smooths the wings of HST ePSFs # more strongly than their cores. A quadratic fit is used here because # the mean of a concave profile in a box is biased high, which changes # the encircled energy. _WING_START = 3.5 _WING_LARGE_START = 5.0 _WING_BLEND = 1.0 _WING_SMALL_BOX = 1.25 _WING_LARGE_BOX = 1.75 _WING_DEGREE = 2 _WING_MIN_SIZE = 5 # Half width (in detector pixels) of the box around each oversampled # grid point within which star pixels contribute to that grid point. # The half width is never smaller than one grid spacing. _DEPOSIT_HALF_WIDTH = 0.375 # Passband of the alias low-pass filter. It ends at the smaller of # _ALIAS_PASS cycles per input pixel and _ALIAS_PASS_NYQUIST times the # Nyquist frequency of the oversampled grid. _ALIAS_PASS = 0.8 _ALIAS_PASS_NYQUIST = 0.7 # Low-pass filter of the refinement iterations (cycles per input pixel), # used only along axes with at least the minimum oversampling factor. # The gain falls to zero at the first zero of the transfer function of # the residual deposit box, which is 1 / (2 * _DEPOSIT_HALF_WIDTH). The # star residuals do not constrain the ePSF at and above that frequency. # Each refinement iteration updates the ePSF _REFINE_STEPS times with # the star centers and fluxes fixed and then refits the stars. _REFINE_PASS = 1.1 _REFINE_STOP = 1.0 / (2.0 * _DEPOSIT_HALF_WIDTH) _REFINE_MIN_OVERSAMPLING = 4 _REFINE_STEPS = 5 def _fitter_accepts_weights(fitter): """ Determine whether a fitter accepts a ``weights`` keyword. Parameters ---------- fitter : callable The fitter to inspect. Returns ------- result : bool `True` if the fitter call signature accepts a ``weights`` keyword (directly or via ``**kwargs``). """ spec = inspect.signature(fitter.__call__) return ('weights' in spec.parameters or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in spec.parameters.values())) def _alias_filter_band(oversampling, *, nu_pass=None, refine=False): """ Return the passband and stopband of the alias low-pass filter along one axis. Parameters ---------- oversampling : int The oversampling factor along the axis. nu_pass : float or `None`, optional The end of the passband of the filter of the building iterations in cycles per input pixel. If `None`, it is the smaller of 0.8 cycles per input pixel and 70% of the Nyquist frequency of the oversampled grid (0.7 cycles per input pixel for an oversampling factor of 2). refine : bool, optional If `True`, return the band of the filter of the refinement iterations, which has unit gain up to 1.1 cycles per input pixel and zero gain at and above 1.33 cycles per input pixel. That filter does not remove the signal of the ePSF near one cycle per input pixel, so it leaves no ripple pattern. It removes only the frequencies that the star residuals do not constrain. It is used only for an oversampling factor of at least 4. For smaller factors those frequencies are too close to the Nyquist frequency of the oversampled grid, and the band of the building iterations is returned. Returns ------- nu_pass, nu_stop : float The end of the passband and the start of the stopband in cycles per input pixel. """ if refine and oversampling >= _REFINE_MIN_OVERSAMPLING: return _REFINE_PASS, _REFINE_STOP if nu_pass is None: nu_pass = min(_ALIAS_PASS, _ALIAS_PASS_NYQUIST * oversampling / 2) return float(nu_pass), 1.0 def _suppress_alias_modes(data, oversampling, *, nu_pass=None, nu_stop=1.0): """ Low-pass filter an oversampled ePSF near and above the input pixel sampling frequency. The filter is applied independently along each axis with an oversampling factor greater than one. It has unit gain up to ``nu_pass`` cycles per input (undersampled) pixel, a raised-cosine transition, and zero gain at and above ``nu_stop`` cycles per input pixel. Frequencies at integer cycles per input pixel are the zeros of the pixel response, so a pixel-integrated PSF has essentially no power there. However, those are exactly the frequencies at which the star-pixel sampling lattice aliases onto the oversampled grid, so noise at those frequencies can grow into a checkerboard pattern during the ePSF build iterations. The default passband is a compromise. The ePSF of an undersampled detector has real power just below one cycle per input pixel. For example, about 10% of the Fourier amplitude of the HST WFC3/IR F110W ePSF lies between 0.7 and 1.0 cycles per pixel. A lower ``nu_pass`` removes that power, which lowers the peak of the ePSF and leaves ripples around its core. On the other hand, a star sampled once per pixel constrains those frequencies only weakly, so a higher ``nu_pass`` slows the convergence of the build, especially for small star samples. The passband is also limited to 70% of the Nyquist frequency of the oversampled grid, which matters only for an oversampling factor of 2. Parameters ---------- data : 2D `~numpy.ndarray` The oversampled ePSF data. oversampling : tuple of int The (y, x) oversampling factors. nu_pass : float, tuple of 2 floats, or `None`, optional The end of the passband in cycles per input pixel, as a single value or as the (y, x) values. If `None`, the passband along each axis ends at the smaller of 0.8 cycles per input pixel and 70% of the Nyquist frequency of the oversampled grid (0.7 cycles per input pixel for an oversampling factor of 2). nu_stop : float or tuple of 2 floats, optional The start of the stopband in cycles per input pixel, as a single value or as the (y, x) values. Returns ------- result : 2D `~numpy.ndarray` The filtered data. The input is returned unchanged if both oversampling factors are one. """ data = np.asarray(data, dtype=float) for axis in (0, 1): factor = int(oversampling[axis]) if factor < 2: continue if nu_pass is None: axis_pass = _alias_filter_band(factor)[0] else: axis_pass = np.broadcast_to(nu_pass, 2)[axis] axis_stop = np.broadcast_to(nu_stop, 2)[axis] npts = data.shape[axis] # Frequency in cycles per input (undersampled) pixel nu = np.abs(np.fft.fftfreq(npts)) * factor frac = np.clip((nu - axis_pass) / (axis_stop - axis_pass), 0.0, 1.0) gain = 0.5 * (1.0 + np.cos(np.pi * frac)) shape = [1, 1] shape[axis] = npts spectrum = np.fft.fft(data, axis=axis) * gain.reshape(shape) data = np.fft.ifft(spectrum, axis=axis).real return data def _odd_size(value): """ Round a positive value to the nearest integer and then up to an odd integer. The result is the nearest integer when that is odd and the next larger integer when it is even, so the rounding is biased upward rather than to the nearest odd integer (e.g., 5.9 gives 7). Parameters ---------- value : float The input value. Returns ------- size : int The odd integer, at least 1. """ size = round(value) if size % 2 == 0: size += 1 return max(size, 1) def _profile_fwhm(profile): """ Measure the full width at half maximum of a 1D profile. The width is measured between the first crossings of the half maximum on each side of the peak, using linear interpolation between the neighboring samples. Parameters ---------- profile : 1D `~numpy.ndarray` The profile. Returns ------- width : float or `None` The width in samples, or `None` if the peak is not finite and positive, if the profile does not cross the half maximum on both sides of the peak within the array, or if a nonfinite sample lies between the peak and a crossing. """ if not np.any(np.isfinite(profile)): return None peak_index = int(np.nanargmax(profile)) peak = profile[peak_index] if not np.isfinite(peak) or peak <= 0: return None half_max = 0.5 * peak edges = [] for step in (-1, 1): i = peak_index while (0 <= i + step < len(profile) and profile[i + step] > half_max): i += step j = i + step if j < 0 or j >= len(profile) or not np.isfinite(profile[j]): return None # Linear interpolation between the last sample above the half # maximum and the first sample at or below it. edges.append(i + step * (profile[i] - half_max) / (profile[i] - profile[j])) return abs(edges[1] - edges[0]) def _measure_fwhm(data): """ Measure the FWHM of a 2D ePSF along each axis. The FWHM is measured along the row and the column through the peak, from the first crossings of the half maximum on each side of the peak. To reduce the sensitivity to noise, the row and column profiles are averaged over the three rows and columns centered on the peak. Parameters ---------- data : 2D `~numpy.ndarray` The ePSF data. Returns ------- fwhm : tuple of float or `None` The (y, x) FWHM in grid points, or `None` if the FWHM could not be measured (e.g., a nonpositive peak or a profile that does not fall below half of the peak within the array). """ data = np.asarray(data, dtype=float) if data.size == 0 or not np.any(np.isfinite(data)): return None iy, ix = np.unravel_index(np.nanargmax(data), data.shape) if data[iy, ix] <= 0: return None # The rows (columns) of a separable PSF are scaled copies of each # other, so averaging the three rows (columns) around the peak # reduces the noise without broadening the profiles. profile_x = data[max(iy - 1, 0):iy + 2, :].mean(axis=0) profile_y = data[:, max(ix - 1, 0):ix + 2].mean(axis=1) fwhm_x = _profile_fwhm(profile_x) fwhm_y = _profile_fwhm(profile_y) if fwhm_x is None or fwhm_y is None: return None return fwhm_y, fwhm_x def _phase_uniformity_pvalue(centers, *, nbins=4): """ Compute a chi-square p-value for the uniformity of the subpixel phases of star centers. Parameters ---------- centers : 2D `~numpy.ndarray` The (x, y) star centers, one row per star. nbins : int, optional The number of phase bins per axis. Returns ------- pvalue : float The p-value of the chi-square test of a uniform phase distribution, combining both axes. """ # scipy.stats is slow to import, so it is imported only when needed. from scipy.stats import chi2 as chi2_dist chi2 = 0.0 for axis in (0, 1): phases = np.mod(centers[:, axis], 1.0) hist = np.histogram(phases, bins=nbins, range=(0.0, 1.0))[0] expected = len(phases) / nbins chi2 += np.sum((hist - expected) ** 2) / expected return chi2_dist.sf(chi2, 2 * (nbins - 1)) def _make_polynomial_kernel(size, *, degree=4): """ Make the kernel of a least-squares polynomial smoother. Convolving an array with this kernel replaces each value by the value at the center of a 2D polynomial of the given degree fit by least squares to the ``size`` x ``size`` values centered on it. The 5x5 ``'quartic'`` and ``'quadratic'`` kernels of `_SmoothingKernel` are the ``degree=4`` and ``degree=2`` cases. The quartic kernel is the smoothing kernel of equation 8 of Anderson and King 2000 (PASP 112, 1360). Parameters ---------- size : int The odd size of the square kernel. degree : int, optional The degree of the 2D polynomial. The number of polynomial terms must be smaller than the number of kernel elements. Returns ------- kernel : 2D `~numpy.ndarray` The smoothing kernel. """ if size < 3 or size % 2 == 0: msg = 'size must be an odd integer greater than or equal to 3' raise ValueError(msg) nterms = (degree + 1) * (degree + 2) // 2 if degree < 1 or nterms >= size**2: msg = ('degree must be at least 1 and the number of polynomial ' 'terms must be smaller than the number of kernel elements') raise ValueError(msg) half = size // 2 yy, xx = np.mgrid[-half:half + 1, -half:half + 1] design = np.array([xx.ravel()**i * yy.ravel()**j for i in range(degree + 1) for j in range(degree + 1 - i)], dtype=float).T # The smoothed value at the center is the center row of the # least-squares projection matrix applied to the data. center = half * size + half weights = design @ np.linalg.solve(design.T @ design, design[center]) return weights.reshape(size, size) 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. """ # The 5x5 least-squares polynomial kernels. The quartic kernel is # equation 8 of Anderson and King 2000. QUARTIC_KERNEL = _make_polynomial_kernel(5, degree=4) QUADRATIC_KERNEL = _make_polynomial_kernel(5, degree=2) # The kernel constructor is a module-level function so that the # class attributes above can be generated when the class is created. make_polynomial_kernel = staticmethod(_make_polynomial_kernel) @classmethod def get_kernel(cls, kernel_type): """ Get a smoothing kernel by type. Parameters ---------- kernel_type : {'quartic', 'quadratic'} or 2D `numpy.ndarray` The type of kernel to retrieve or a custom kernel array. A custom kernel must be a `numpy.ndarray`. Other array-like inputs (e.g., a nested list) are not accepted. Returns ------- kernel : 2D `numpy.ndarray` The smoothing kernel. Raises ------ TypeError If `kernel_type` is not supported. ValueError If a custom kernel array is not 2D. Notes ----- The predefined kernels are 5x5 least-squares polynomial smoothing kernels generated by ``make_polynomial_kernel``. The ``'quartic'`` kernel (degree 4) is the smoothing kernel of equation 8 of Anderson and King 2000 (PASP 112, 1360), and the ``'quadratic'`` kernel is the degree 2 counterpart. """ if isinstance(kernel_type, np.ndarray): if kernel_type.ndim != 2: msg = 'smoothing_kernel must be a 2D array' raise ValueError(msg) 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'}, 2D `numpy.ndarray`, or `None` The type of kernel to use for smoothing, a custom kernel array (which must be a `numpy.ndarray`), 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 collects the validation of the input stars and of several `EPSFBuilder` parameters, 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 the star list is empty, if any star cutout is smaller than 3 pixels along an axis, if ``shape`` is not a 2-element sequence, or if ``shape`` is smaller than the minimum ePSF shape required by the star cutouts and the oversampling factors. The last message includes the minimum required shape. """ if not stars: msg = ('Cannot validate shape compatibility with empty star list. ' 'Please provide at least one star for ePSF building.') raise ValueError(msg) # Iterate over the flat star list so that the stars within # LinkedEPSFStar objects are validated individually star_list = getattr(stars, 'all_stars', stars) # Collect star dimension statistics star_heights = [star.shape[0] for star in star_list] star_widths = [star.shape[1] for star in star_list] 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(star_list): 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 the minimum required ePSF shape, consistent with # _CoordinateTransformer.compute_epsf_shape (add 1 only when # the product is even, to ensure odd dimensions) min_epsf_height = max_height * oversampling[0] if min_epsf_height % 2 == 0: min_epsf_height += 1 min_epsf_width = max_width * oversampling[1] if min_epsf_width % 2 == 0: min_epsf_width += 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) @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 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 the individual stars in the flat star list so that # the stars within LinkedEPSFStar objects are checked (their # container delegates attributes like shape as lists). The # flat indices in error messages match excluded_star_indices. star_list = getattr(stars, 'all_stars', stars) invalid_stars = [] for i, star in enumerate(star_list): 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(star_list)} 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 ------ TypeError If center accuracy is not a number. ValueError If center accuracy is not positive. """ if not isinstance(center_accuracy, numbers.Real): 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_converged_fraction(converged_fraction): """ Validate the converged fraction parameter. Parameters ---------- converged_fraction : float The fraction of the successfully fitted stars that must converge. Raises ------ TypeError If converged_fraction is not a number. ValueError If converged_fraction is not in the range (0, 1]. """ if (isinstance(converged_fraction, bool) or not isinstance(converged_fraction, numbers.Real)): msg = (f'converged_fraction must be a number, got ' f'{type(converged_fraction)}') raise TypeError(msg) if not 0.0 < converged_fraction <= 1.0: msg = (f'converged_fraction must be in the range (0, 1], got ' f'{converged_fraction}') raise ValueError(msg) @staticmethod def validate_maxiters(maxiters): """ Validate maximum iterations parameter. Parameters ---------- maxiters : int The maximum number of iterations. Raises ------ TypeError If maxiters is not an integer. ValueError If maxiters is not positive. """ if isinstance(maxiters, bool) or not isinstance(maxiters, numbers.Integral): 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 provides the conversions between the input star coordinate system and the oversampled ePSF coordinate system that are shared by the ePSF building steps. 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 : `~numpy.ndarray` 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 : `~numpy.ndarray` of int 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 : float or `~numpy.ndarray` 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 : float or `~numpy.ndarray` Coordinates in the undersampled (original) grid. Returns ------- x_over, y_over : float or `~numpy.ndarray` 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. desc : str or `None`, optional The description of the progress bar. If `None`, the description of the building iterations is used. Attributes ---------- enabled : bool Whether progress reporting is active. maxiters : int Maximum iterations for progress bar setup. desc : str The description of the progress bar. _pbar : progress bar or `None` The underlying progress bar instance. """ def __init__(self, enabled, maxiters, *, desc=None): """ Initialize a _ProgressReporter. Parameters ---------- enabled : bool Whether progress reporting is enabled. maxiters : int The maximum number of iterations. desc : str or `None`, optional The description of the progress bar. """ self.enabled = enabled self.maxiters = maxiters if desc is None: desc = f'EPSFBuilder ({maxiters} maxiters)' self.desc = desc 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 self._pbar = add_progress_bar(total=self.maxiters, desc=self.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('EPSFBuilder building iterations converged ' f'after {iteration} iterations (of ' f'{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() class _IterationRecord(NamedTuple): """ The ePSF and the convergence statistics of one building or refinement iteration. """ epsf_data: np.ndarray stage: str converged: bool converged_fraction: float max_center_dist_sq: float common_center_dist_sq: float n_fit_failed: int @dataclass class EPSFBuildResults: """ 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 building iterations performed. This will be <= maxiters specified in EPSFBuilder. The refinement iterations are not counted. converged : bool Whether the building process converged based on the center accuracy criterion. `True` if at least the ``converged_fraction`` of the successfully fitted stars moved by less than the specified center accuracy between the final iterations. If the ePSF was refined, ``converged``, ``final_center_accuracy``, and ``final_converged_fraction`` are measured in the last refinement iteration, so that they describe the returned ``fitted_stars``. A build whose building iterations converged can therefore report `False`. The first refit of the refinement moves some stars by more than the center accuracy, because the ePSF changes when the refinement restores its signal near one cycle per pixel. This mostly happens with a single refinement iteration (``refinement_iters=1``). The star centers settle again within a few refinement iterations. With different oversampling factors along the two axes they can need more refinement iterations than the default (about 10 in tests). The ``converged`` column of ``iteration_info`` gives the convergence of every building and refinement iteration. final_center_accuracy : float The maximum center displacement in the final iteration, in pixels, over all of the successfully fitted stars, relative to the median displacement of those stars. This includes the stars that the ``converged_fraction`` of the builder allows to remain unconverged, so it can be much larger than the ``center_accuracy`` for a converged build. Use it together with ``final_converged_fraction`` to assess the convergence quality. final_converged_fraction : float The fraction of the successfully fitted stars whose centers changed by less than ``center_accuracy`` in the final iteration. The build is converged when this fraction is at least the ``converged_fraction`` of the builder. n_excluded_stars : int The number of individual stars (including those from linked stars) that were excluded from fitting. A star is excluded when its fit fails in any iteration after the third. 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). smoothing_kernel : 2D `~numpy.ndarray` or `None` The smoothing kernel applied in the final iteration, or `None` if no smoothing was applied. This includes the kernel chosen by ``smoothing_kernel='auto'``, which can be input as a fixed ``smoothing_kernel`` to reproduce the build. fit_shape : tuple or `None` The (ny, nx) shape of the fitting box used in the final iteration, or `None` if the entire star cutouts were fit. This includes the shape chosen by ``fit_shape='auto'``. iteration_epsfs : list of 2D `~numpy.ndarray` The ePSF image after each iteration, in order. The building iterations come first, followed by the refinement iterations (if any). Each image has the shape and normalization of ``epsf.data``. The images can be used to check how the ePSF evolved and whether it stopped changing. See also `~photutils.psf.EPSFBuildResults.plot_iterations`. The smoothing of the wings of the final ePSF (see the ``wing_smoothing`` keyword of `EPSFBuilder`) is not an iteration, so the last image is the ePSF before its wings were smoothed, and it differs from ``epsf.data`` in the wings if that smoothing changed the ePSF. iteration_info : `~astropy.table.Table` A table with one row for each image in ``iteration_epsfs``. The columns are the iteration number (``iteration``, starting at 1), the kind of iteration (``stage``, ``'build'`` or ``'refine'``), whether the star centers had converged in that iteration (``converged``), the fraction of the successfully fitted stars whose centers moved by less than the center accuracy in that iteration (``converged_fraction``), the largest center movement in pixels relative to the median movement (``max_center_shift``), the size of the median movement in pixels (``common_center_shift``, which is zero if fewer than 3 stars were successfully fitted), the number of stars whose fit failed (``n_fit_failed``), and the largest absolute change of the ePSF image from the previous iteration as a fraction of the ePSF peak (``max_epsf_change``). The change of the first iteration is measured from ``initial_epsf``, or from an empty ePSF if the ePSF was built from scratch. The last row gives the ``converged``, ``final_converged_fraction``, and ``final_center_accuracy`` values of the results. An iteration has not converged while ``common_center_shift`` is not less than the ``center_accuracy`` of the builder, even if the ``converged_fraction`` is large enough. The last ``'build'`` row tells whether the building iterations converged, which ``converged`` alone does not when the ePSF was refined. initial_epsf : 2D `~numpy.ndarray` or `None` The image of the input ePSF that the build started from, or `None` if the ePSF was built from scratch. center_asymmetry : 1D `~numpy.ndarray` or `None` The ``(x, y)`` offset, in detector pixels, of the center of mass of the final ePSF from its symmetry center. The center of mass is measured in a 5x5 pixel box, and the symmetry center is the point about which the core of the ePSF is most symmetric (see `~photutils.centroids.centroid_symmetry`). The two centers agree for a symmetric ePSF. For an asymmetric ePSF, this offset is the amount by which the star positions measured with the ePSF depend on the definition of its center (the ``recentering_func`` keyword of `EPSFBuilder`). Positions measured with an ePSF centered on its center of mass are larger by this offset than positions measured with the same ePSF centered on its symmetry center. The value does not depend on which definition was used to build the ePSF. It is `None` if the ePSF is not larger than the 5x5 pixel box, where the centers cannot be measured reliably. 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 smoothing_kernel: np.ndarray | None = field(default=None, compare=False, repr=False) fit_shape: tuple | None = None final_converged_fraction: float | None = None iteration_epsfs: list | None = field(default=None, compare=False, repr=False) iteration_info: Table | None = field(default=None, compare=False, repr=False) initial_epsf: np.ndarray | None = field(default=None, compare=False, repr=False) center_asymmetry: np.ndarray | None = field(default=None, compare=False, repr=False) 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 __len__(self): """ Return the length of the backward-compatible 2-tuple. Returns ------- length : int Always 2, for (epsf, fitted_stars). """ return 2 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 = 'EPSFBuildResults index must be 0 (epsf) or 1 (fitted_stars)' raise IndexError(msg) def plot_iterations(self, *, iterations=None, show_smoothed=None, figsize=None, cmap='viridis', diff_cmap='RdBu_r'): """ Plot the ePSF after each iteration and its change from the previous iteration. The figure has one row for each iteration. The left panel shows the ePSF with a logarithmic stretch that is common to all the rows. The right panel shows the difference from the ePSF of the previous iteration, as a fraction of the peak of the ePSF of the last iteration, with a symmetric linear stretch of its own. For the first iteration the right panel shows the difference from ``initial_epsf``, or the ePSF itself if the ePSF was built from scratch. If the wings of the final ePSF were smoothed (see the ``wing_smoothing`` keyword of `EPSFBuilder`), the figure can have one more row at the bottom. It shows the returned ePSF and its difference from the ePSF of the last iteration, which is the change made by the wing smoothing. By default, this row is plotted only if all the iterations are plotted. Use the ``show_smoothed`` keyword to always or never plot it. Parameters ---------- iterations : int, 1D array_like of int, or `None`, optional The iteration numbers (starting at 1, as in the ``iteration`` column of ``iteration_info``) to plot. If `None`, all the iterations are plotted. The figure has one row per iteration, so select a few iterations to get a compact figure. show_smoothed : bool or `None`, optional Whether to plot the row of the wing-smoothed final ePSF. If `None`, the row is plotted only if all the iterations are plotted. If `True`, the row is always plotted. If `False`, the row is never plotted. This keyword has no effect if the wing smoothing did not change the ePSF. figsize : tuple of 2 float or `None`, optional The figure (width, height) in inches. If `None`, the figure is 7 inches wide and 2.6 inches tall per row. cmap : str or `matplotlib.colors.Colormap`, optional The colormap of the ePSF panels. diff_cmap : str or `matplotlib.colors.Colormap`, optional The colormap of the difference panels. Returns ------- fig : `matplotlib.figure.Figure` The figure. 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. """ import matplotlib.pyplot as plt from astropy.visualization import simple_norm if not self.iteration_epsfs or self.iteration_info is None: msg = 'There is no per-iteration history to plot' raise ValueError(msg) n_total = len(self.iteration_epsfs) if iterations is None: iterations = np.arange(1, n_total + 1) iterations = np.atleast_1d(iterations) if (iterations.ndim != 1 or iterations.size == 0 or not np.issubdtype(iterations.dtype, np.integer)): msg = ('iterations must be an integer or a nonempty 1D array ' 'of integers') raise ValueError(msg) if np.any(iterations < 1) or np.any(iterations > n_total): msg = f'iterations must be between 1 and {n_total}' raise ValueError(msg) final = self.iteration_epsfs[-1] peak = np.max(final) norm = simple_norm(final, 'log', percent=99.0) # The wing smoothing is not an iteration, so the smoothed ePSF # is plotted in a row of its own (None) after the iterations. rows = list(iterations) if show_smoothed is None: show_smoothed = np.unique(iterations).size == n_total if show_smoothed and not np.array_equal(self.epsf.data, final): rows.append(None) n_rows = len(rows) if figsize is None: figsize = (7.0, 2.6 * n_rows) fig, axes = plt.subplots(n_rows, 2, figsize=figsize, squeeze=False) for row, iteration in enumerate(rows): if iteration is None: data = self.epsf.data previous = final title = 'Final ePSF (wings smoothed)' subtitle = '' else: data = self.iteration_epsfs[iteration - 1] if iteration > 1: previous = self.iteration_epsfs[iteration - 2] elif self.initial_epsf is not None: previous = self.initial_epsf else: previous = 0.0 info = self.iteration_info[iteration - 1] title = f'Iteration {iteration} ({info["stage"]})' subtitle = ('\nconverged fraction ' f'{info["converged_fraction"]:.2f}') diff = (data - previous) / peak limit = np.max(np.abs(diff)) if limit == 0: limit = 1.0 ax = axes[row, 0] axim = ax.imshow(data, norm=norm, origin='lower', cmap=cmap) fig.colorbar(axim, ax=ax) ax.set_title(title) ax = axes[row, 1] axim = ax.imshow(diff, origin='lower', cmap=diff_cmap, vmin=-limit, vmax=limit) fig.colorbar(axim, ax=ax) ax.set_title(f'change / peak (max {limit:.2g}){subtitle}', fontsize=9) fig.tight_layout() return fig @deprecated(since='3.0', message=('EPSFFitter is deprecated and will be removed in ' 'version 4.0. Use EPSFBuilder with the fitter, ' 'fit_shape, and fitter_maxiters parameters instead.'), warning_type=PhotutilsDeprecationWarning) 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') self._fitter_accepts_weights = _fitter_accepts_weights(self.fitter) 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. The ``flux``, ``x_0``, and ``y_0`` parameters of this model are overwritten during the fit. 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. A star that is excluded from fitting, or whose fit box is not fully inside the cutout, is fully masked, or gives a fitted position outside the cutout, keeps its input center and flux. The centers of linked stars are then constrained to the same sky coordinate. """ 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] mask = star.mask[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 mask = star.mask # 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] # Fit only the unmasked pixels. Masked pixels (nonfinite data # or nonpositive weights) are dropped from the fit because the # fitter objective function raises on nonfinite data values # even where the weight is zero. good = ~mask if not np.any(good): star._fit_error_status = 4 # fitting region is fully masked return star xx = xx[good] yy = yy[good] data = data[good] weights = weights[good] # Define the initial guesses for fitted flux and shifts epsf.flux = star.flux epsf.x_0 = 0.0 epsf.y_0 = 0.0 if self._fitter_accepts_weights: fitted_epsf = fitter(model=epsf, x=xx, y=yy, z=data, weights=weights, **fitter_kwargs) else: 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 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). The method is based on `Anderson and King 2000 (PASP 112, 1360) `_ and `Anderson 2016 (WFC3 ISR 2016-12) `_. It differs from them in several steps (see Notes). Parameters ---------- oversampling : int or array_like (int), optional 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. The ePSF should have at least about four grid points per FWHM, so the value should be at least ``4 / FWHM`` with the FWHM in pixels. The default of 4 is a good choice for a FWHM of about 1 pixel or more. A value above ``4 / FWHM`` increases the run time and the memory use, and in tests a value of 8 was no more accurate than 4. For well-sampled data (a FWHM of 4 pixels or more), a value of 1 gives the same fitted star positions and fluxes in less time. See the guidelines in the ePSF building user guide for details. shape : int, tuple of two ints, 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 (with a warning). 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 : str, 2D `~numpy.ndarray`, or `None`, optional The smoothing kernel to apply to the ePSF during each iteration step. A string must be ``'auto'``, ``'quartic'``, or ``'quadratic'``. If ``'auto'``, a least-squares quartic polynomial kernel (see Notes) whose width is the largest odd number of oversampled grid points that is not larger than 0.7 times the FWHM of the current ePSF is used, and no smoothing is applied if that width is less than 5 grid points, i.e., for ePSFs with fewer than about 7 grid points per FWHM. The kernel is square and the FWHM is measured in each iteration along the narrowest axis of the ePSF, so with anisotropic oversampling the axis with the fewer grid points per FWHM sets the kernel size. If the FWHM cannot be measured, the ``'quartic'`` kernel is used and a warning is emitted. The predefined ``'quartic'`` and ``'quadratic'`` kernels are 5x5 kernels (in oversampled grid points) derived from fourth and second degree polynomials, respectively. Alternatively, a custom 2D array can be input. If `None` then no smoothing will be performed. The kernels are applied on the oversampled grid, so the physical width of a fixed kernel depends on the oversampling factor. Structure that repeats with a period of about one input pixel or shorter is removed from the ePSF along oversampled axes independently of this parameter (see ``alias_passband``). alias_passband : {'auto'}, float, or `None`, optional The end of the passband, in cycles per detector pixel, of the low-pass filter that is applied to the ePSF in each iteration along the axes with an oversampling factor greater than one. A spatial frequency of one cycle per pixel describes structure that repeats with a period of one detector pixel, and a frequency of 0.5 cycles per pixel describes structure that repeats every two pixels. The filter has unit gain up to ``alias_passband``, a raised-cosine transition, and zero gain at and above one cycle per pixel. It removes the frequencies at which the star-pixel sampling lattice aliases onto the oversampled grid, which otherwise can grow into a checkerboard pattern (see Notes). The value must be greater than 0 and less than 1. If ``'auto'`` (default), the passband ends at 0.8 cycles per pixel, or at 0.7 cycles per pixel for an oversampling factor of 2 (70% of the Nyquist frequency of the oversampled grid). The default is the best choice for most data. A different value can help in two cases, which are distinguished by the optical cutoff frequency of the telescope in cycles per pixel, ``cutoff = D * pixel_scale / wavelength``, with the telescope diameter ``D`` and the mean ``wavelength`` of the bandpass in the same units and the ``pixel_scale`` in radians per pixel: * ``cutoff`` greater than about 1 (strongly undersampled, e.g., HST WFC3/IR F110W, JWST NIRCam F070W, or Roman WFI F062 and F106): the ePSF has real signal up to nearly one cycle per pixel. In tests the default recovered the peak of such ePSFs to within about 1 percent, except for the sharpest ones, whose peak was 3 percent low. A value of 0.85 with a ``maxiters`` of 15 to 20 recovered the peaks to within 0.3 percent and lowered the residuals in the core by 10 to 35 percent, except for the sharpest ePSF, which needed 0.9. Try 0.9 only if the ePSF built with 0.85 is still too broad, i.e., if the stars have positive residuals at their centers after the fitted ePSF is subtracted. A value above the default needs a large star sample (a few hundred stars), an ``oversampling`` of 4 or more, and more iterations. If the stars were observed in several dithered exposures, link them and keep the default instead (see ``constrain_fluxes``). In tests, linking the stars of four exposures recovered the peaks to within 0.4 percent. * ``cutoff`` less than about 0.9 (e.g., JWST NIRCam F115W and redder, JWST MIRI, or most ground-based data): the ePSF has no signal to preserve near one cycle per pixel. A value of 0.7 rejects more noise (10 to 40 percent lower residuals in the core in tests) and converges in fewer iterations. It leaves the peak of a strongly undersampled ePSF low by 2 to 6 percent, so use it only when ``cutoff`` is known. A value larger than needed makes the build converge more slowly, because a star sampled once per pixel constrains the frequencies near one cycle per pixel only weakly, and it is not recommended for small star samples or for an oversampling factor of 2. If `None`, the filter is not applied. This is rarely appropriate. Without the filter, noise at the alias frequencies accumulates over the iterations, the build can stall before it converges, and heterogeneous or contaminated star samples can grow a checkerboard pattern. In tests with simulated HST, JWST, and Roman star fields, the unfiltered ePSF was less accurate than the filtered one in nearly every case, even for large, clean, and homogeneous star samples. The option is provided for experimentation, e.g., to check how much the filter changes a particular ePSF. Always compare the result with a filtered build. The filter is never applied along an axis with an oversampling factor of 1. 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. The default is a `~astropy.stats.SigmaClip` with ``sigma=3.0`` and ``maxiters=10``. 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` and have a ``mask`` keyword. The callable object must return a tuple of (x, y) centroids. The default is `~photutils.centroids.centroid_symmetry`, the point about which the core of the ePSF is most symmetric, which is the center definition of Anderson 2016. With the default ``recentering_boxsize`` it measures the symmetry within about 1.5 detector pixels of the center. It needs a box of at least 5 oversampled pixels and an ePSF that is not constant. If it cannot be calculated (e.g., with a ``recentering_boxsize`` of 3 and an ``oversampling`` of 1), the center of mass (`~photutils.centroids.centroid_com`) is used instead, and a warning is emitted if the box is too small. The center of mass is also used for a recentering step in which the ePSF is far from the center of the box (e.g., if the input star positions share a large offset), where the symmetry center cannot be found. This fallback applies only to `~photutils.centroids.centroid_symmetry` itself and not to a function that wraps it. The center of mass of an asymmetric ePSF is pulled toward the asymmetric structure around its core, so the two definitions give centers that differ by a constant offset (up to about 0.1 pixel for undersampled space-telescope PSFs). recentering_boxsize : int or tuple of two ints, 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 when the centers of at least ``converged_fraction`` of the successfully fitted stars change by less than ``center_accuracy`` pixels between iterations (see ``converged_fraction``). converged_fraction : float, optional The fraction of the successfully fitted stars whose centers must change by less than ``center_accuracy`` pixels between iterations for the build to be considered converged. The change of each center is measured relative to the median change of all the centers, which must also be less than ``center_accuracy``. A change that is common to all the stars is a shift of the center of the ePSF. It changes neither the shape of the ePSF nor the relative positions of the stars. The default of 0.95 allows a small number of stars (e.g., spurious detections or contaminated cutouts) whose centers never settle to not prevent convergence. Set to 1.0 to require all stars to converge. The fraction achieved in the final iteration is reported in the ``final_converged_fraction`` attribute of the returned `EPSFBuildResults`. 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 : {'auto'}, int, tuple of int, or `None`, optional The size (in detector 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 ``'auto'``, a square box of twice the FWHM of the current ePSF (measured in each iteration along its narrowest axis, in detector pixels), with a minimum of 5 pixels and a maximum of the smallest star cutout size, is used. For an elongated ePSF, the narrower axis sets the box size along both axes. If the FWHM cannot be measured, a 5x5 box is used and a warning is emitted. 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. constrain_fluxes : bool, optional Whether to constrain the fluxes of the stars within each `~photutils.psf.LinkedEPSFStar` (i.e., the same physical star observed in multiple dithered images) to their mean value after each fitting iteration, in addition to constraining their centers to the same sky coordinate. This breaks the degeneracy between the flux of a star and its subpixel position caused by intra-pixel sensitivity variations, which would otherwise be absorbed into the ePSF. It assumes that the linked images have the same flux scale (e.g., the same exposure time and throughput). Set to `False` if the linked images have different flux scales. This parameter has no effect on stars that are not linked. Linking the stars of dithered exposures is the recommended way to build the ePSF of an undersampled detector. In tests with simulated star fields, the peaks of strongly undersampled ePSFs built from four linked exposures were recovered to within 0.4 percent with this constraint and to within about 1 percent without it, compared to as much as 3 percent low for one exposure. maxiters : int, optional The maximum number of ePSF building iterations to perform. refinement_iters : int, optional The number of refinement iterations to perform after the building iterations. The alias low-pass filter of the building iterations (see ``alias_passband``) removes some real signal of an undersampled ePSF just below one cycle per pixel. The filter acts separately along each axis, so the missing signal shows as a ripple pattern with a period of about one pixel along the row and the column through the center of the ePSF. The refinement iterations restore that signal. In each refinement iteration, the ePSF is updated five times from the star residuals with the star centers and fluxes held fixed, and the stars are then refit with the updated ePSF. The ePSF is recentered in each update, as in the building iterations, which keeps the ePSF and the star centers from drifting together. The low-pass filter of these updates has unit gain up to 1.1 cycles per pixel and zero gain at and above 1.33 cycles per pixel, so it does not remove signal near one cycle per pixel. It removes only the frequencies that the star residuals do not constrain. Such a wide filter cannot be used from the start of the build, because the build then converges slowly and is more sensitive to the initial star centers. The refinement is not performed if ``refinement_iters`` is 0, if ``alias_passband`` is `None`, or if the oversampling factor is less than 4 along both axes. It roughly doubles the run time of a build. The ``converged`` attribute of the results describes the last refinement iteration. The first refit of the refinement moves some stars by more than ``center_accuracy``, so with ``refinement_iters=1`` a build whose building iterations converged can report ``converged=False``. The star centers settle again within a few refinement iterations. With different oversampling factors along the two axes they can need more refinement iterations than the default (about 10 in tests). wing_smoothing : bool, optional Whether to smooth the wings of the final ePSF more strongly than its core. Far from the center the ePSF is faint and varies slowly, so its noise can be averaged over a larger area than in the core. If `True`, each value of the final ePSF beyond 3.5 FWHM from its center is blended into the value of a least-squares quadratic fit to the values in a box 1.25 FWHM wide around it, and beyond 5 FWHM into the fit in a box 1.75 FWHM wide. The ePSF within 3.5 FWHM of its center is not changed, except by the renormalization of the smoothed ePSF (less than 0.03 percent in tests). The smoothing is applied once, after the last iteration, so it does not affect the star fits or the convergence of the build. The fluxes of the returned stars were therefore fit before that renormalization. It is modeled on the approach of Anderson 2016, which smooths the wings of HST ePSFs more strongly than their cores. In tests with a few hundred stars it lowered the residuals of the wings of undersampled ePSFs by up to about 50 percent. This matters when the wings are used, e.g., to subtract bright stars, to make model images, or to measure encircled energies. The smoothing also removes real structure in the wings that is finer than about two FWHM, such as diffraction rings and spikes. Applied to noise-free ePSFs, it changed the wings by 2 to 8 percent of their mean value for JWST and Roman ePSFs and by 11 to 14 percent for HST WFC3/IR ePSFs. With a few hundred stars the noise that it removes is larger than this in every case that was tested. For a large star sample of high signal-to-noise (thousands of stars), the noise in the wings can be smaller than this change, and the wings are then more accurate without the smoothing. The boxes are at least 5 oversampled grid points wide. For an ePSF with a FWHM of less than 4 grid points they are therefore wider than given above, and they remove more of the real structure. This matters most for an oversampling factor of 1, where the boxes of an undersampled ePSF (a FWHM of about 1.3 pixels) are nearly 4 FWHM wide. In tests with an oversampling factor of 1, the smoothing made the wings of the most undersampled HST and JWST ePSFs less accurate, by up to a factor of about 2, and those of most other ePSFs slightly more accurate. With an oversampling factor of 2 or larger it made the wings more accurate or left them unchanged in every case. For an oversampling factor of 1, compare the ePSFs built with and without the smoothing. Set to `False` to keep the wings as built. progress_bar : bool, optional Whether to print the progress bar during the build iterations. The progress bar requires that the `tqdm `_ optional dependency be installed. Notes ----- In each build iteration, the residual between each star and the current ePSF model is deposited on the oversampled grid points within 0.375 detector pixel (and at least one grid spacing) of each star pixel center along each axis. Each grid point is therefore estimated from the star pixels in a box three quarters of a pixel wide around it, and every star contributes to both parities of the grid regardless of its subpixel phase. Anderson and King (2000) combined the pixels within 0.25 pixel of each grid point of an ePSF with an oversampling factor of 4. That narrower box gives a slightly sharper ePSF for clean star samples, but it grows a checkerboard pattern for heterogeneous or contaminated ones. After the residuals are combined and the ePSF is smoothed, power is removed along each oversampled axis with a low-pass filter that by default has unit gain up to 0.8 cycles per input pixel (0.7 for an oversampling factor of 2) and zero gain at and above one cycle per input pixel (see ``alias_passband``). A pixel-integrated PSF has essentially no power at one cycle per input pixel, but that is the frequency at which the star-pixel sampling lattice aliases onto the oversampled grid. Without these two measures, noise in the ePSF grid from heterogeneous or contaminated stars can bias the fitted star centers toward particular subpixel phases and grow into a checkerboard pattern in the ePSF. A warning is emitted if the subpixel phases of the fitted star centers are strongly nonuniform at the end of the build. The default ``smoothing_kernel='auto'`` and ``fit_shape='auto'`` scale the smoothing kernel and the fitting box with the FWHM of the ePSF. The 5x5 ``'quartic'`` kernel (in oversampled grid points) of Anderson and King 2000 and the 5-pixel fitting box (in detector pixels) of Anderson 2016 were designed for HST images with an oversampling factor of 4, where they correspond to about 0.7 and 2.5 FWHM. A fixed kernel oversmooths heavily undersampled ePSFs, and a fixed 5-pixel fitting box applied to a well-sampled star uses only its flat core, which biases the fitted centers and can prevent convergence. The polynomial kernels replace each grid value by the value at the center of a least-squares polynomial fit to the surrounding grid values. The kernel and fitting box chosen in the final iteration are reported in the ``smoothing_kernel`` and ``fit_shape`` attributes of the returned `EPSFBuildResults`. This class follows the ePSF building procedure of Anderson and King 2000 and Anderson 2016, with several modifications. The main differences are listed below. See the ePSF building user guide (:ref:`epsf-anderson-differences`) for the complete comparison and the reasons for each difference. * A single ePSF is built with any integer oversampling factor. Anderson builds a 3x3 array of ePSFs across each detector with an oversampling factor of 4. * The star cutouts must be background subtracted by the user. Anderson measures the background of each star in an annulus around it. * The residuals are combined in a box of 0.375 pixel half width with a sigma-clipped median (3 sigma by default). Anderson uses a half width of 0.25 pixel and a mean with rejection at 2.5 sigma. * The smoothing kernel and the wing smoothing scale with the FWHM of the ePSF, and the wings are smoothed only once, after the last iteration. Anderson uses a 5x5 quartic kernel in the core and stronger smoothing beyond fixed radii of 3 to 5 pixels, in every iteration. * A low-pass Fourier filter is applied in every iteration for oversampling factors greater than 1 (see ``alias_passband``). Anderson applies none. * The ePSF is centered on its point of maximal symmetry within a radius of about 1.5 pixels by default, as in Anderson (2016). Anderson (2000) requires equal values half a pixel on either side of the center. * The ePSF is normalized so that its values sum to the product of the oversampling factors over the whole grid. Fitted fluxes are therefore the fluxes within the area of the grid. Anderson normalizes the ePSF to unit flux in the central 5x5 pixels (2000) or within a radius of 5.5 pixels (2016). * The stars are fit after every ePSF update until their centers converge, and the ePSF is then refined with five updates per fit (see ``refinement_iters``). Anderson uses five updates per fit throughout and iterates until the fitted positions and fluxes show no trend with pixel phase. * The stars are fit in a box of twice the FWHM with a nonlinear least-squares fitter. Anderson fits the pixels within about 2 pixels of the center (2000) or the central 5x5 pixels (2016) with Poisson weights. * Dithered exposures are optional (see `~photutils.psf.LinkedEPSFStar`). They are central to Anderson's method, which averages the positions and fluxes of each star over the exposures after every fit. * The ePSF is evaluated with a single bicubic spline over the whole grid. Anderson uses a bicubic spline within 4 pixels of the center and bilinear interpolation farther out (2016). This class stores per-call state on the instance (e.g., the automatic smoothing kernel), so a single instance must not be called concurrently from multiple threads. Create one instance per thread for concurrent use. Sharing a single Astropy fitter instance across concurrently-used objects is also unsafe because Astropy fitters store ``fit_info`` on themselves. """ coord_transformer = deprecated_attribute( 'coord_transformer', '3.1', warning_type=PhotutilsDeprecationWarning) def __init__(self, *, oversampling=4, shape=None, smoothing_kernel='auto', alias_passband='auto', sigma_clip=SIGMA_CLIP, recentering_func=centroid_symmetry, recentering_boxsize=(5, 5), recentering_maxiters=20, center_accuracy=1.0e-3, converged_fraction=0.95, fitter=None, fit_shape='auto', fitter_maxiters=100, constrain_fluxes=True, maxiters=10, refinement_iters=5, wing_smoothing=True, 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: shape = as_pair('shape', shape, lower_bound=(0, 0)) even = shape % 2 == 0 if np.any(even): new_shape = shape + even.astype(int) msg = (f'The input shape {tuple(shape)} has even ' f'values. Using {tuple(new_shape)} instead ' 'for proper ePSF centering.') warnings.warn(msg, AstropyUserWarning) shape = new_shape self.shape = shape self.recentering_func = recentering_func msg = 'recentering_maxiters must be a strictly-positive integer' if (isinstance(recentering_maxiters, bool) or not isinstance(recentering_maxiters, numbers.Integral)): raise TypeError(msg) if recentering_maxiters <= 0: raise ValueError(msg) self.recentering_maxiters = int(recentering_maxiters) self.recentering_boxsize = as_pair('recentering_boxsize', recentering_boxsize, lower_bound=(3, 1), check_odd=True) if (recentering_func is centroid_symmetry and np.any(self.recentering_boxsize * self.oversampling < 4)): msg = ('The recentering box (recentering_boxsize times ' 'oversampling) is smaller than the 5 oversampled pixels ' 'that centroid_symmetry needs. The ePSF will be centered ' 'on its center of mass (centroid_com) instead.') warnings.warn(msg, AstropyUserWarning) if isinstance(smoothing_kernel, str): if smoothing_kernel not in ('auto', 'quartic', 'quadratic'): msg = ("smoothing_kernel must be 'auto', 'quartic', " "'quadratic', a 2D array, or None") raise ValueError(msg) elif smoothing_kernel is not None: # Validate early so bad kernels fail at construction # instead of in the middle of a build. _SmoothingKernel.get_kernel(smoothing_kernel) self.smoothing_kernel = smoothing_kernel if not (alias_passband is None or self._is_auto(alias_passband)): msg = ("alias_passband must be 'auto', a number between " '0 and 1 (exclusive), or None') if (isinstance(alias_passband, bool) or not isinstance(alias_passband, (str, numbers.Real))): raise TypeError(msg) if (isinstance(alias_passband, str) or not 0.0 < alias_passband < 1.0): raise ValueError(msg) alias_passband = float(alias_passband) self.alias_passband = alias_passband # Per-call state for the automatic smoothing kernel and fit # shape (reset in build_epsf and updated in each iteration). self._auto_state = None self._auto_fit_max = None self._auto_fallback_warned = False # 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, PhotutilsDeprecationWarning) 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 isinstance(fit_shape, str) and not self._is_auto(fit_shape): msg = ("fit_shape must be 'auto', an integer, a tuple of " 'integers, or None') raise ValueError(msg) if fit_shape is None or self._is_auto(fit_shape): self.fit_shape = fit_shape else: self.fit_shape = as_pair('fit_shape', fit_shape, lower_bound=(3, 1), check_odd=True) # 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') self._fitter_accepts_weights = _fitter_accepts_weights(self.fitter) if not isinstance(constrain_fluxes, (bool, np.bool_)): msg = 'constrain_fluxes must be a bool' raise TypeError(msg) self.constrain_fluxes = bool(constrain_fluxes) # Validate center accuracy using the validator _EPSFValidator.validate_center_accuracy(center_accuracy) self.center_accuracy_sq = center_accuracy**2 # Validate converged_fraction using the validator _EPSFValidator.validate_converged_fraction(converged_fraction) self.converged_fraction = float(converged_fraction) # Validate maxiters using the validator _EPSFValidator.validate_maxiters(maxiters) self.maxiters = maxiters msg = 'refinement_iters must be a nonnegative integer' if (isinstance(refinement_iters, bool) or not isinstance(refinement_iters, numbers.Integral)): raise TypeError(msg) if refinement_iters < 0: raise ValueError(msg) self.refinement_iters = int(refinement_iters) if not isinstance(wing_smoothing, (bool, np.bool_)): msg = 'wing_smoothing must be a bool' raise TypeError(msg) self.wing_smoothing = bool(wing_smoothing) 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 sigma_clip is not None and not isinstance(sigma_clip, SigmaClip): msg = ('sigma_clip must be an astropy.stats.SigmaClip ' 'instance or None') raise TypeError(msg) self._sigma_clip = sigma_clip def __call__(self, stars): """ Build an ePSF from input stars. Parameters ---------- stars : `EPSFStars` The stars used to build the ePSF. Returns ------- result : `EPSFBuildResults` The result of the ePSF building process. """ return self.build_epsf(stars) @staticmethod def _is_auto(value): """ Return whether a parameter value is the string ``'auto'``. """ return isinstance(value, str) and value == 'auto' def _auto_fallback(self): """ Return the fallback state used when the ePSF FWHM cannot be measured, warning once per build. """ fit_size = _AUTO_FIT_MIN_SIZE if self._auto_fit_max is not None: fit_size = min(fit_size, self._auto_fit_max) if not self._auto_fallback_warned: auto_kernel = self._is_auto(self.smoothing_kernel) auto_fit = self._is_auto(self.fit_shape) if auto_kernel and auto_fit: what = ('smoothing_kernel and fit_shape fall back to the ' f"'quartic' kernel and a fit shape of {fit_size}") elif auto_kernel: what = "smoothing_kernel falls back to the 'quartic' kernel" else: what = f'fit_shape falls back to a fit shape of {fit_size}' msg = ('The FWHM of the ePSF could not be measured, so the ' f"'auto' {what}.") warnings.warn(msg, AstropyUserWarning) self._auto_fallback_warned = True return {'kernel': _SmoothingKernel.QUARTIC_KERNEL, 'fit_shape': (fit_size, fit_size)} def _update_auto_parameters(self, epsf_data): """ Choose the smoothing kernel and fit shape from the FWHM of the current ePSF. The smoothing window is the largest odd size that is not larger than ``_AUTO_KERNEL_FWHM_FRACTION`` times the FWHM in oversampled grid points (no smoothing below ``_AUTO_KERNEL_MIN_SIZE``), and the fit shape is ``_AUTO_FIT_FWHM_FRACTION`` times the FWHM in detector pixels (at least ``_AUTO_FIT_MIN_SIZE`` and at most the smallest star cutout size). The FWHM is measured along the narrowest axis of the ePSF. Parameters ---------- epsf_data : 2D `~numpy.ndarray` The current (unsmoothed) ePSF data. """ if not (self._is_auto(self.smoothing_kernel) or self._is_auto(self.fit_shape)): return fwhm = _measure_fwhm(epsf_data) if fwhm is None: self._auto_state = self._auto_fallback() return fwhm_grid = min(fwhm) fwhm_pixels = min(fwhm[0] / self.oversampling[0], fwhm[1] / self.oversampling[1]) kernel = None # Cap the kernel at the largest odd size smaller than the ePSF. # A kernel as large as the ePSF would fit every grid value # mostly to edge-reflected data. # The window is rounded down to an odd size so that the kernel # is never wider than the requested fraction of the FWHM size = int(_AUTO_KERNEL_FWHM_FRACTION * fwhm_grid) if size % 2 == 0: size -= 1 size = min(size, _odd_size(min(epsf_data.shape)) - 2) if size >= _AUTO_KERNEL_MIN_SIZE: kernel = _SmoothingKernel.make_polynomial_kernel(size, degree=4) fit_size = max(_AUTO_FIT_MIN_SIZE, _odd_size(_AUTO_FIT_FWHM_FRACTION * fwhm_pixels)) if self._auto_fit_max is not None: fit_size = min(fit_size, self._auto_fit_max) self._auto_state = {'kernel': kernel, 'fit_shape': (fit_size, fit_size)} def _current_smoothing_kernel(self): """ Return the smoothing kernel for the current iteration. """ if self._is_auto(self.smoothing_kernel): return self._auto_state['kernel'] return self.smoothing_kernel def _current_fit_shape(self): """ Return the fit shape for the current iteration. """ if self._is_auto(self.fit_shape): return self._auto_state['fit_shape'] return self.fit_shape 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. """ if isinstance(fitter_maxiters, bool) or not isinstance( fitter_maxiters, numbers.Integral): msg = ('fitter_maxiters must be an integer, got ' f'{type(fitter_maxiters)}') raise TypeError(msg) if fitter_maxiters <= 0: msg = 'fitter_maxiters must be a positive integer' raise ValueError(msg) 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. The ePSF shape is the configured ``shape`` if provided, otherwise it is derived from the maximum star cutout dimensions and the oversampling factors (made odd along each axis so the ePSF has a well-defined central pixel). The origin is set to the geometric center of the data array. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. Returns ------- epsf : `ImagePSF` object The initial zero-data ePSF model. """ 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 (use the flat star list so that stars within # LinkedEPSFStar objects contribute individual shapes) star_shapes = [star.shape for star in stars.all_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_residuals(self, stars, epsf, *, chunk_size=128): """ Compute normalized residual images in the oversampled ePSF grid for all the input stars. A normalized residual image is calculated for each star 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 by depositing each star pixel value on the oversampled grid points within 0.375 detector pixel (and at least one grid spacing) of the pixel center along each axis. Each grid point is therefore estimated from the star pixels in a box three quarters of a pixel wide around it. Anderson and King (2000) used a box half a pixel wide (pixels within 0.25 pixel of each grid point of an ePSF with an oversampling factor of 4). The wider box is needed for heterogeneous or contaminated star samples, which grow a checkerboard pattern with the narrower one. Every star contributes to both parities of the grid along each axis, regardless of its subpixel phase. For an oversampling factor of one along an axis, this reduces to the nearest grid point. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. Stars that are excluded from fitting are skipped. epsf : `ImagePSF` object The ePSF model. chunk_size : int, optional The number of stars processed together. The stars are resampled in chunks so that the temporary per-pixel arrays stay bounded for large star samples. Returns ------- epsf_resid : 3D `~numpy.ndarray` A 3D cube containing the resampled residual images, one per star that is not excluded from fitting. The images contain NaNs where there is no data. """ ny, nx = epsf.data.shape good_stars = stars.all_good_stars n_good_stars = len(good_stars) # Preallocate with NaN (default for missing data) epsf_resid = np.full((n_good_stars, ny, nx), np.nan) for start in range(0, n_good_stars, chunk_size): stop = min(start + chunk_size, n_good_stars) self._deposit_residuals(good_stars[start:stop], epsf, epsf_resid[start:stop]) return epsf_resid @staticmethod def _deposit_half_width(oversampling): """ Return the half width, in oversampled grid spacings, of the box around each star pixel center within which the pixel is deposited on the ePSF grid points along one axis. The half width is ``_DEPOSIT_HALF_WIDTH`` detector pixels, but at least one grid spacing so that every star pixel reaches both parities of the grid. For an oversampling factor of one it is half a grid spacing, i.e., the nearest grid point. Parameters ---------- oversampling : int The oversampling factor along the axis. Returns ------- half_width : float The half width in oversampled grid spacings. """ if oversampling < 2: return 0.5 return max(_DEPOSIT_HALF_WIDTH * oversampling, 1.0) def _deposit_residuals(self, stars, epsf, epsf_resid): """ Deposit the normalized residuals of the input stars into a stack of oversampled ePSF grid images. Parameters ---------- stars : list of `EPSFStar` The stars to resample. epsf : `ImagePSF` object The ePSF model. epsf_resid : 3D `~numpy.ndarray` The contiguous stack of residual images, with one image per input star, that is filled in place. Grid points that are not covered by a star pixel are left unchanged. """ ny, nx = epsf.data.shape # Gather the unmasked pixels of all stars so that the ePSF model # is evaluated once and the residuals are deposited with a # single indexed assignment per grid offset. star_index = [] xidx_centered = [] yidx_centered = [] residuals = [] for i, star in enumerate(stars): xidx, yidx = star._xyidx_centered star_index.append(np.full(xidx.size, i)) xidx_centered.append(xidx) yidx_centered.append(yidx) residuals.append(star._data_values_normalized) star_index = np.concatenate(star_index) xidx_centered = np.concatenate(xidx_centered) yidx_centered = np.concatenate(yidx_centered) # The concatenation is a new array, so the model can be # subtracted in place. residuals = np.concatenate(residuals) residuals -= epsf.evaluate(x=xidx_centered, y=yidx_centered, flux=1.0, x_0=0.0, y_0=0.0) # Each star pixel is deposited on the oversampled grid points # k with x_over - w < k <= x_over + w along each axis, where # x_over is the pixel center in the oversampled ePSF grid and w # is the deposit half width in grid spacings. Compute the first # of these grid points and the largest number of them along # each axis. ny_over, nx_over = self.oversampling y_width = self._deposit_half_width(ny_over) x_width = self._deposit_half_width(nx_over) x_over, y_over = self._coord_transformer.undersampled_to_oversampled( xidx_centered, yidx_centered) x_over = x_over + epsf.origin[0] y_over = y_over + epsf.origin[1] x_first = np.floor(x_over - x_width).astype(int) + 1 y_first = np.floor(y_over - y_width).astype(int) + 1 ny_deposit = int(np.ceil(2 * y_width)) nx_deposit = int(np.ceil(2 * x_width)) # Deposit each pixel residual on these grid points through a # flat index into the stack, which needs only two masked copies # per grid offset. The masks along the x-axis are the same for # every row offset, so they are computed once. epsf_resid_flat = epsf_resid.reshape(-1) star_offset = star_index * (ny * nx) x_masks = [((x_first + i) >= 0) & ((x_first + i) < nx) & ((x_first + i) <= x_over + x_width) for i in range(nx_deposit)] for j in range(ny_deposit): yidx = y_first + j y_mask = (yidx >= 0) & (yidx < ny) & (yidx <= y_over + y_width) row_index = star_offset + yidx * nx for i in range(nx_deposit): mask = y_mask & x_masks[i] flat_index = row_index + (x_first + i) epsf_resid_flat[flat_index[mask]] = residuals[mask] 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. The input array is returned unchanged if the smoothing kernel of the current iteration is `None`. """ return _SmoothingKernel.apply_smoothing( epsf_data, self._current_smoothing_kernel()) def _smooth_wings(self, epsf_data): """ Smooth the wings of the final ePSF more strongly than its core. Far from the center the ePSF is faint and varies slowly, so the noise there can be averaged over a larger area than in the core. This also removes real structure that is finer than about two FWHM. Beyond 3.5 FWHM from the center, each value of the ePSF is blended into the value at the center of a least-squares quadratic fit to the values in a box 1.25 FWHM wide around it, and beyond 5 FWHM into the fit in a box 1.75 FWHM wide. The ePSF within 3.5 FWHM of the center is not changed here. The caller renormalizes the result, which rescales the whole ePSF by the small change of its sum. The FWHM is measured along the narrowest axis of the ePSF, and the boxes are square on the oversampled grid and at least ``_WING_MIN_SIZE`` grid points wide. The smoothing is applied once, to the final ePSF. Applying it in every iteration would compound its effect and couple the wings to the normalization of the ePSF. Parameters ---------- epsf_data : 2D `~numpy.ndarray` The ePSF image. Returns ------- result : 2D `~numpy.ndarray` The ePSF image with smoothed wings. ``epsf_data`` is returned if it has nonfinite values, if the FWHM of the ePSF could not be measured, or if no part of the image is in the wings. """ # The box fits use an FFT convolution, which would spread a # nonfinite value over the whole image if not np.all(np.isfinite(epsf_data)): return epsf_data fwhm = _measure_fwhm(epsf_data) if fwhm is None: return epsf_data oversampling = np.asarray(self.oversampling, dtype=float) fwhm = float(np.min(np.asarray(fwhm) / oversampling)) # pixels # Radius from the center in units of the FWHM ny, nx = epsf_data.shape yy, xx = np.indices((ny, nx), dtype=float) yy = (yy - (ny - 1) / 2.0) / oversampling[0] xx = (xx - (nx - 1) / 2.0) / oversampling[1] radius = np.hypot(xx, yy) / fwhm weight_small = np.clip((radius - _WING_START) / _WING_BLEND, 0.0, 1.0) if not np.any(weight_small > 0): return epsf_data weight_large = np.clip((radius - _WING_LARGE_START) / _WING_BLEND, 0.0, 1.0) # scipy.signal is slow to import, so it is imported only when # needed. from scipy.signal import fftconvolve def box_fit(width): size = max(_odd_size(width * fwhm * float(np.min(oversampling))), _WING_MIN_SIZE) kernel = _make_polynomial_kernel(size, degree=_WING_DEGREE) # The kernel is symmetric and the image is extended with # its edge values, which is the same as a convolution with # mode='nearest' but much faster for large boxes. padded = np.pad(epsf_data, size // 2, mode='edge') return fftconvolve(padded, kernel, mode='valid') wings = box_fit(_WING_SMALL_BOX) if np.any(weight_large > 0): wings = ((1.0 - weight_large) * wings + weight_large * box_fit(_WING_LARGE_BOX)) return (1.0 - weight_small) * epsf_data + weight_small * wings 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 not np.isfinite(current_sum) or current_sum <= 0: msg = (f'Cannot normalize ePSF: the data sum ({current_sum}) ' 'is not finite and positive. This usually indicates ' 'a problem in the recentering or smoothing steps, ' 'e.g., a recentering function that returned ' 'nonfinite centroids.') raise ValueError(msg) return epsf_data * (oversampling_product / current_sum) @staticmethod def _centroid_cutout(centroid_func, cutout, mask): """ Calculate the centroid of an ePSF cutout. The symmetry center cannot be calculated for a cutout that is too small for it or that has no source (e.g., constant data). It is also searched only near the center of the cutout. If the source lies outside of that area, the result is on the edge of the area and is not the center of the source. The center of mass, which always moves toward the source, is used in those cases. Parameters ---------- centroid_func : callable The centroid function. cutout : 2D `~numpy.ndarray` The cutout of the ePSF data. mask : 2D bool `~numpy.ndarray` The mask of the nonfinite values of ``cutout``. Returns ------- xcenter, ycenter : float The centroid in the pixel coordinates of the cutout. """ if centroid_func is centroid_symmetry: # centroid_symmetry needs at least 4 pixels along each axis # and a radius (0.3 times the smaller size) of at least 1 centroid = np.full(2, np.nan) if min(cutout.shape) >= 4: centroid = centroid_symmetry(cutout, mask=mask) # The search area of centroid_symmetry center = (np.array(cutout.shape[::-1]) - 1) / 2 margin = center - 0.3 * min(cutout.shape) if np.any(np.abs(centroid - center) > margin - 1.0e-3): centroid = np.full(2, np.nan) if not np.all(np.isfinite(centroid)): centroid = centroid_com(cutout, mask=mask) return centroid return centroid_func(cutout, mask=mask) def _find_epsf_center(self, epsf, *, centroid_func=None, box_size=None, maxiters=None, center_accuracy=None): """ Find the center of the ePSF and shift it to the array center. This method uses iterative centroiding to find the center of the ePSF and applies subpixel shifts using spline interpolation via the ImagePSF ``evaluate`` method. Parameters ---------- epsf : `ImagePSF` object The ePSF model containing the data to be recentered. centroid_func : callable, optional A callable object used to calculate the centroid of a 2D array. The callable must accept a 2D `~numpy.ndarray` and have a ``mask`` keyword. The callable object must return a tuple of (x, y) scalar centroids. If `None`, uses the builder's configured recentering_func. box_size : int or tuple of two ints, optional The size (in pixels) of the box used to calculate the centroid of the ePSF during each iteration, 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 `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`` oversampled 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. shift : tuple of 2 floats The ``(x, y)`` offset of the measured center from the center of the ePSF grid, in undersampled pixels. """ # 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 = self._centroid_cutout( centroid_func, epsf_cutout, 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, (dx_total, dy_total) def _measure_center_asymmetry(self, epsf): """ Measure the offset of the center of mass of an ePSF from its symmetry center. Both centers are measured in a 5x5 pixel box at the center of the ePSF grid, independent of the recentering function and box of the builder, by iterating until the center found in the box is the center of the box. Parameters ---------- epsf : `ImagePSF` object The ePSF model. Returns ------- offset : 1D `~numpy.ndarray` or `None` The ``(x, y)`` offset in undersampled pixels, or `None` if the ePSF is not larger than the box. """ # The box in oversampled pixels, as in _find_epsf_center box = 5 * self.oversampling box += 1 - box % 2 if np.any(np.array(epsf.data.shape) <= box): return None centers = [] for centroid_func in (centroid_com, centroid_symmetry): _, shift = self._find_epsf_center( epsf, centroid_func=centroid_func, box_size=(5, 5), maxiters=20) centers.append(shift) return np.subtract(*centers) def _build_epsf_step(self, stars, *, epsf=None, refine=False): """ 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. refine : bool, optional Whether this is an update of a refinement iteration. The alias low-pass filter then uses the wider refinement filter. 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) if self._sigma_clip is not None: 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 # Choose the automatic smoothing kernel and fit shape from the # FWHM of the current ePSF. self._update_auto_parameters(new_epsf) # Smooth the ePSF smoothed_data = self._smooth_epsf(new_epsf) # Remove power near and above the input pixel sampling frequency # along oversampled axes, where the star-pixel lattice aliases # onto the ePSF grid. if self.alias_passband is not None: nu_pass = None if not self._is_auto(self.alias_passband): nu_pass = self.alias_passband bands = [_alias_filter_band(factor, nu_pass=nu_pass, refine=refine) for factor in self.oversampling] smoothed_data = _suppress_alias_modes( smoothed_data, self.oversampling, nu_pass=(bands[0][0], bands[1][0]), nu_stop=(bands[0][1], bands[1][1])) # Recenter the ePSF using an intermediate ePSF that keeps the # current epsf's origin. The recentering shifts the ePSF by # evaluating its spline on the shifted grid, so the edge row # and column on one side fall outside the original grid. They # take the spline value at the nearest point on the grid edge # (fill_value=None) rather than being set to zero, which would # leave an all-zero row and column at that edge. temp_epsf = ImagePSF(data=smoothed_data, origin=epsf.origin, oversampling=self.oversampling, fill_value=None) # Apply recentering to the smoothed data recentered_data, _ = self._find_epsf_center(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 the movement of the star centers between iterations. The build has converged when at least ``converged_fraction`` of the successfully fitted stars moved by less than the configured center accuracy. The movement is measured relative to the median movement of the stars if at least 3 stars were successfully fitted. A shift of the center of the ePSF moves all of the star centers by the same amount, which changes neither the shape of the ePSF nor the relative positions of the stars. The median movement must itself be less than the center accuracy. 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. converged_fraction : float The fraction of the successfully fitted stars whose centers moved by less than the center accuracy. max_center_dist_sq : float The maximum squared center movement of the successfully fitted stars, relative to their median movement. common_center_dist_sq : float The squared median movement of the successfully fitted stars. It is zero if fewer than 3 stars were successfully fitted. It is NaN, as is ``max_center_dist_sq``, if no star was successfully fitted. 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, so convergence cannot be determined. This # is unreachable from build_epsf (all-failed fits raise # earlier), but guards direct calls. NaN indicates that no # center movement could be measured. return False, 0.0, np.nan, np.nan, new_centers # Remove the movement that is common to the stars. The median # of fewer than 3 stars is not a common movement. dx_dy_good = dx_dy[good_stars] common_dist_sq = 0.0 if len(dx_dy_good) >= 3: common = np.median(dx_dy_good, axis=0) common_dist_sq = float(np.sum(common * common)) dx_dy_good = dx_dy_good - common center_dist_sq = np.sum(dx_dy_good * dx_dy_good, axis=1, dtype=np.float64) converged_fraction = float( np.mean(center_dist_sq < self.center_accuracy_sq)) converged = (converged_fraction >= self.converged_fraction and common_dist_sq < self.center_accuracy_sq) return (converged, converged_fraction, float(np.max(center_dist_sq)), common_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. The centers of linked stars are then constrained to the same sky coordinate, and their fluxes to their mean if ``constrain_fluxes`` is `True`. Stars that are excluded from fitting are returned unchanged. """ if len(stars) == 0: return stars if not isinstance(epsf, ImagePSF): msg = 'The input epsf must be an ImagePSF' raise TypeError(msg) # Build the cached interpolators of a custom interpolator once # so that the model copies made by the fitter for every star # share them instead of each rebuilding them. epsf._precompute_interpolators() 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): if star.all_excluded: # Pass through unchanged to prevent # constrain_centers from warning on every iteration # for a fully excluded linked star. fitted_star = star else: 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() if self.constrain_fluxes: fitted_star.constrain_fluxes() 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` A shallow copy of the star with the fitted cutout center and flux. The center and flux are not changed if the fitting box does not fit within the cutout, if the fitting region is fully masked, or if the fitted center is outside of the cutout. """ fit_shape = self._current_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] mask = star.mask[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 mask = star.mask # 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] # Fit only the unmasked pixels. Masked pixels (nonfinite data # or nonpositive weights) are dropped from the fit because the # fitter objective function raises on nonfinite data values # even where the weight is zero. good = ~mask if not np.any(good): star._fit_error_status = 4 # fitting region is fully masked return star xx = xx[good] yy = yy[good] data = data[good] weights = weights[good] # Define the initial guesses for fitted flux and shifts epsf.flux = star.flux epsf.x_0 = 0.0 epsf.y_0 = 0.0 if self._fitter_accepts_weights: fitted_epsf = fitter(model=epsf, x=xx, y=yy, z=data, weights=weights, **fitter_kwargs) else: 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, *, refine=False): """ Process a single building or refinement iteration. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `ImagePSF` object or `None` The current ePSF model. If `None`, the ePSF is built from scratch. iter_num : int Current iteration number, counting the building and the refinement iterations. refine : bool, optional Whether this is a refinement iteration, in which the ePSF is updated ``_REFINE_STEPS`` times with the refinement filter and the star centers and fluxes held fixed before the stars are refit. 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 n_steps = _REFINE_STEPS if refine else 1 for _ in range(n_steps): epsf = self._build_epsf_step(stars, epsf=epsf, refine=refine) # 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') elif star._fit_error_status == 4: reason = ('its fitting region contains no ' 'unmasked pixels') else: # _fit_error_status == 2 reason = 'the fit did not converge' label = '' if star.id_label is not None: label = f' (id={star.id_label})' msg = (f'The star at ' f'({star._center_original[0]:.2f}, ' f'{star._center_original[1]:.2f}) ' f'(index={i}){label} has been excluded ' f'from ePSF fitting because {reason}.') warnings.warn(msg, AstropyUserWarning) star._excluded_from_fit = True return epsf, stars, fit_failed @staticmethod def _warn_nonuniform_phases(stars, *, min_stars=32, pvalue=1.0e-6): """ Warn if the subpixel phases of the fitted star centers are strongly nonuniform. Unbiased star centers have uniformly distributed subpixel phases. A strongly nonuniform distribution indicates that the fitted centers are biased toward particular phases, which happens when the ePSF grid is noisy (e.g., for heterogeneous, contaminated, or low signal-to-noise stars). Parameters ---------- stars : `EPSFStars` object The fitted stars. min_stars : int, optional The minimum number of successfully fitted stars needed to perform the check. pvalue : float, optional The chi-square p-value below which the warning is emitted. """ centers = [star.cutout_center for star in stars.all_stars if not star._excluded_from_fit and star._fit_error_status == 0] if len(centers) < min_stars: return if _phase_uniformity_pvalue(np.array(centers)) < pvalue: msg = ('The subpixel phases of the fitted star centers are ' 'strongly nonuniform, which indicates that the star ' 'centers are biased. The ePSF and the fitted star ' 'centers may be unreliable. This can be caused by ' 'stars with different PSFs, contaminated or saturated ' 'star cutouts, spurious detections, or low ' 'signal-to-noise stars. Consider using a cleaner ' 'star sample or a lower oversampling factor.') warnings.warn(msg, AstropyUserWarning) def _finalize_build(self, epsf, stars, iter_num, converged, final_center_accuracy, final_converged_fraction=None, *, history=None, initial_epsf=None): """ Finalize the ePSF building process and create result object. The excluded star indices are derived from the per-star exclusion flags, which are the single source of truth. Parameters ---------- epsf : `ImagePSF` object Final ePSF model. stars : `EPSFStars` object Final fitted stars. iter_num : int Number of completed building iterations. The refinement iterations are not counted. converged : bool Whether the building process converged. final_center_accuracy : float Final center accuracy achieved. final_converged_fraction : float, optional The fraction of the successfully fitted stars whose centers changed by less than the center accuracy in the final iteration. history : list of `_IterationRecord` or `None`, optional The record of each building and refinement iteration. initial_epsf : 2D `~numpy.ndarray` or `None`, optional The image of the input ePSF that the build started from. Returns ------- result : `EPSFBuildResults` Structured result containing ePSF, stars, and build diagnostics. """ excluded_star_indices = [i for i, star in enumerate(stars.all_stars) if star._excluded_from_fit] # Warn if the fitted star centers have strongly nonuniform # subpixel phases. self._warn_nonuniform_phases(stars) # Create structured result. The kernel is copied so that the # results own it and edits cannot reach the shared predefined # kernels or the input array. kernel = self._current_smoothing_kernel() if kernel is not None: kernel = _SmoothingKernel.get_kernel(kernel).copy() fit_shape = self._current_fit_shape() if fit_shape is not None: fit_shape = tuple(int(size) for size in fit_shape) iteration_epsfs = None iteration_info = None if history is not None: iteration_epsfs = [record.epsf_data for record in history] iteration_info = self._make_iteration_info( history, initial_epsf=initial_epsf) center_asymmetry = self._measure_center_asymmetry(epsf) return EPSFBuildResults( 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, smoothing_kernel=kernel, fit_shape=fit_shape, final_converged_fraction=final_converged_fraction, iteration_epsfs=iteration_epsfs, iteration_info=iteration_info, initial_epsf=initial_epsf, center_asymmetry=center_asymmetry, ) 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. Its oversampling must match the ``oversampling`` of the builder, and its shape must be at least the largest star cutout shape times the oversampling (plus one along an axis where that product is even). The output ePSF has the shape of this model. If `None`, then the ePSF will be built from scratch. Returns ------- result : `EPSFBuildResults` The ePSF building results, with detailed information about the building process. For backward compatibility, the result can be unpacked as a tuple: ``(epsf, fitted_stars) = epsf_builder(stars)``. Raises ------ ValueError If the oversampling or shape of the input ``epsf`` is incompatible with the builder or the stars, if the ``shape`` of the builder is too small for the stars, if the stars are invalid, or if the ePSF fit fails for all 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 - final_converged_fraction: Fraction of stars that met the accuracy - n_excluded_stars: Number of stars excluded due to fit failures - excluded_star_indices: Indices of excluded stars - smoothing_kernel: Smoothing kernel of the final iteration - fit_shape: Fitting box of the final iteration - iteration_epsfs: ePSF image after each iteration - iteration_info: Table of the convergence statistics of each iteration - initial_epsf: Image of the input ePSF, if any - center_asymmetry: Offset of the center of mass of the final ePSF from its symmetry center """ initial_epsf = None if epsf is not None: initial_epsf = epsf.data.copy() if not np.array_equal(epsf.oversampling, self.oversampling): msg = (f'The input epsf oversampling ' f'{tuple(epsf.oversampling)} does not match the ' f'builder oversampling ' f'{tuple(self.oversampling)}') raise ValueError(msg) # An undersized initial ePSF would silently truncate the # result, so validate its shape like a requested shape _EPSFValidator.validate_shape_compatibility( stars, self.oversampling, shape=epsf.data.shape) _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_all_stars, dtype=bool) centers = stars.cutout_center_flat # Reset the per-call automatic kernel and fit shape state. The # automatic fit shape cannot exceed the smallest star cutout. self._auto_state = None self._auto_fallback_warned = False min_cutout = min(min(star.shape) for star in stars.all_stars) self._auto_fit_max = _odd_size(min_cutout) if self._auto_fit_max > min_cutout: self._auto_fit_max -= 2 # Setup progress tracking progress_reporter = _ProgressReporter(self.progress_bar, self.maxiters).setup() # Initialize iteration variables iter_num = 0 converged = False converged_fraction = 0.0 max_center_dist_sq = self.center_accuracy_sq + 1.0 # Main iteration loop. Note that an all-failed iteration # raises inside _process_iteration, so no fit_failed exit # condition is needed here. history = [] while iter_num < self.maxiters and not converged: iter_num += 1 # Process one iteration epsf, stars, fit_failed = self._process_iteration( stars, epsf, iter_num) # Check convergence based on center movements (converged, converged_fraction, max_center_dist_sq, common_center_dist_sq, centers) = self._check_convergence(stars, centers, fit_failed) history.append(_IterationRecord( epsf.data.copy(), 'build', converged, converged_fraction, max_center_dist_sq, common_center_dist_sq, int(np.sum(fit_failed)))) # Update progress bar progress_reporter.update() final_center_accuracy = float(max_center_dist_sq ** 0.5) # Finish the progress reporting of the building iterations # before the refinement, which has its own progress bar. if iter_num < self.maxiters: progress_reporter.write_convergence_message(iter_num) progress_reporter.close() # Refine the ePSF. Each refinement iteration updates the ePSF # with the star centers and fluxes fixed and then refits the # stars with the updated ePSF. The convergence diagnostics then # describe the refit of the last refinement iteration, so that # they match the returned stars. if (self.refinement_iters > 0 and self.alias_passband is not None and np.any(self.oversampling >= _REFINE_MIN_OVERSAMPLING)): refine_reporter = _ProgressReporter( self.progress_bar, self.refinement_iters, desc='EPSFBuilder refinement').setup() for refine_num in range(1, self.refinement_iters + 1): epsf, stars, fit_failed = self._process_iteration( stars, epsf, iter_num + refine_num, refine=True) (converged, converged_fraction, max_center_dist_sq, common_center_dist_sq, centers) = self._check_convergence(stars, centers, fit_failed) history.append(_IterationRecord( epsf.data.copy(), 'refine', converged, converged_fraction, max_center_dist_sq, common_center_dist_sq, int(np.sum(fit_failed)))) refine_reporter.update() refine_reporter.close() final_center_accuracy = float(max_center_dist_sq ** 0.5) # Smooth the wings of the final ePSF. The ePSF is replaced only # if the smoothing changed it, so that an ePSF without wings is # exactly the ePSF of the last iteration. if self.wing_smoothing: smoothed = self._smooth_wings(epsf.data) if smoothed is not epsf.data: epsf = ImagePSF(data=self._normalize_epsf(smoothed), oversampling=self.oversampling, fill_value=0.0) # Finalize and return structured results return self._finalize_build(epsf, stars, iter_num, converged, final_center_accuracy, converged_fraction, history=history, initial_epsf=initial_epsf) @staticmethod def _make_iteration_info(history, *, initial_epsf=None): """ Make the table of per-iteration diagnostics. Parameters ---------- history : list of `_IterationRecord` The record of each iteration. It has at least one record. initial_epsf : 2D `~numpy.ndarray` or `None`, optional The image of the input ePSF that the build started from, which is the reference for the change of the first iteration. If `None`, the reference is an empty ePSF. Returns ------- table : `~astropy.table.Table` The table described in `EPSFBuildResults`. """ peak = np.max(history[-1].epsf_data) changes = [] previous = 0.0 if initial_epsf is None else initial_epsf for record in history: change = np.max(np.abs(record.epsf_data - previous)) / peak changes.append(float(change)) previous = record.epsf_data table = Table() table['iteration'] = np.arange(1, len(history) + 1) table['stage'] = [record.stage for record in history] table['converged'] = [bool(record.converged) for record in history] table['converged_fraction'] = [float(record.converged_fraction) for record in history] table['max_center_shift'] = [float(record.max_center_dist_sq) ** 0.5 for record in history] table['common_center_shift'] = [ float(record.common_center_dist_sq) ** 0.5 for record in history] table['n_fit_failed'] = [record.n_fit_failed for record in history] table['max_epsf_change'] = changes table['converged_fraction'].info.format = '.3f' table['max_center_shift'].info.format = '.3g' table['common_center_shift'].info.format = '.3g' table['max_epsf_change'].info.format = '.3g' return table def __getattr__(name): # EPSFBuildResult was renamed to EPSFBuildResults in 3.1. if name == 'EPSFBuildResult': msg = ('EPSFBuildResult is deprecated and will be removed in ' 'version 4.0. Use EPSFBuildResults instead.') warnings.warn(msg, PhotutilsDeprecationWarning, stacklevel=2) return EPSFBuildResults msg = f'module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) astropy-photutils-24be529/photutils/psf/epsf_stars.py000066400000000000000000001620361526452531300231660ustar00rootroot00000000000000# 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 from functools import cached_property import astropy.units as u import numpy as np from astropy.nddata import (NDData, NoOverlapError, PartialOverlapError, StdDevUncertainty) from astropy.table import Table from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import BoundingBox from photutils.psf.utils import _interpolate_missing_data from photutils.utils._overlap import overlap_slices from photutils.utils._parameters import as_pair from photutils.utils._wcs_helpers import (_low_level_wcs, _pixel_to_world_radians) __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``. Pixels where ``weights`` is not positive are treated as masked. Nonfinite weights are set to zero, and a warning is issued. Pixels where ``data`` is not finite are also masked, and a warning is issued. 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. It must be finite. If `None`, then the flux will be estimated from the input ``data`` (see `estimate_flux`). In that case, a `ValueError` is raised if all pixels are masked. 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): if isinstance(data, u.Quantity): msg = ('data must be a plain ndarray. Quantity inputs are ' 'not supported') raise TypeError(msg) if isinstance(data, np.ma.MaskedArray): msg = ('data must be a plain ndarray. MaskedArray inputs ' 'are not supported. Use the weights keyword to mask ' 'pixels') raise TypeError(msg) 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 = ('Nonfinite 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 {origin.size}' raise ValueError(msg) if not np.all(np.isfinite(origin)): msg = 'Origin coordinates must be finite' raise ValueError(msg) if np.any(np.mod(origin, 1) != 0): msg = 'origin must have integer values' 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._fit_info = None def __array__(self, dtype=None, copy=None): """ Array representation of the data array (e.g., for matplotlib). """ if copy is False: return np.asarray(self._data, dtype=dtype) return np.array(self._data, dtype=dtype, copy=True) @property def data(self): """ The 2D cutout image. """ return self._data @property def flux(self): """ The total flux of the star. """ return self._flux @flux.setter def flux(self, value): """ Set the total flux of the star. Parameters ---------- value : float The total flux of the star. """ value = float(value) if not np.isfinite(value): msg = 'flux must be a finite value' raise ValueError(msg) self._flux = value # The cached normalized data values depend on the flux self.__dict__.pop('_data_values_normalized', None) @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. This is initially the input ``cutout_center`` value, or the geometric center of the cutout if that was `None`. It is updated during the ePSF building iterations to 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.array(value, dtype=float) @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 @cached_property 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[0]), slice(self.origin[0], self.origin[0] + self.shape[1])) @cached_property 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 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 @cached_property def _data_values_normalized(self): """ 1D array of unmasked cutout data values, normalized by the star's total flux. For a star with all-zero cutout data (and therefore zero estimated flux), the values are all NaN and the star contributes nothing to an ePSF build. A zero flux with nonzero data gives infinite values. """ with np.errstate(divide='ignore', invalid='ignore'): return self.data[~self.mask].ravel() / self.flux class EPSFStars: """ Class to hold a list of `EPSFStar` and/or `LinkedEPSFStar` objects. Parameters ---------- stars_list : `EPSFStar`, `LinkedEPSFStar`, or list of these A single `EPSFStar` or `LinkedEPSFStar` object, or a list of `EPSFStar` and/or `LinkedEPSFStar` objects. """ def __init__(self, stars_list): msg = ('stars_list must be a list of EPSFStar and/or ' 'LinkedEPSFStar objects') if isinstance(stars_list, (EPSFStar, LinkedEPSFStar)): self._data = [stars_list] elif isinstance(stars_list, list): for star in stars_list: if not isinstance(star, (EPSFStar, LinkedEPSFStar)): raise TypeError(msg) self._data = stars_list else: 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] for key in ('all_stars', 'n_stars', 'n_all_stars'): self.__dict__.pop(key, None) def __iter__(self): """ Iterate over the stars in this container. """ yield from self._data def __array__(self, dtype=None, copy=None): """ Array representation of the star cutout data (e.g., for matplotlib). A container holding a single star returns the 2D cutout of that star. Otherwise, the cutouts of all stars are stacked along the first axis (which requires that they have the same shape). """ data = self._data[0] if len(self._data) == 1 else self._data return np.array(data, dtype=dtype, copy=copy) 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. If the per-star values have inconsistent shapes (e.g., for a mix of `EPSFStar` and multistar `LinkedEPSFStar` objects), an object array is returned. Private attributes (other than ``_excluded_from_fit``) are not delegated, and empty containers raise `AttributeError`. """ if attr != '_excluded_from_fit' and attr.startswith('_'): msg = (f"'{type(self).__name__}' object has no attribute " f"'{attr}'") raise AttributeError(msg) if not self._data: msg = (f"'{type(self).__name__}' object is empty and has " f"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']: try: result = np.array(result) except ValueError: # Ragged per-star values (e.g., a mix of EPSFStar and # multistar LinkedEPSFStar objects) arr = np.empty(len(result), dtype=object) arr[:] = result result = arr 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 more than one item and any of them is a `LinkedEPSFStar` with more than one star, the ``cutout_center`` attribute is not a 2D array. It is a 3D array if every item is a `LinkedEPSFStar` with the same number of stars, and an object array of per-item arrays otherwise. """ 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 more than one item and any of them is a `LinkedEPSFStar` with more than one star, the ``center`` attribute is not a 2D array. It is a 3D array if every item is a `LinkedEPSFStar` with the same number of stars, and an object array of per-item arrays otherwise. """ return np.array([star.center for star in self.all_stars]) @cached_property 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 @cached_property def n_stars(self): """ The total number of stars. A linked star is counted only once. """ return len(self._data) @cached_property 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 nonexcluded 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 and, by default, the same flux. 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 == '_excluded_from_fit': return np.array([star._excluded_from_fit for star in self._data]) 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. The ``cutout_center`` of each nonexcluded star is updated in place. Excluded stars are left unchanged. If all of the stars have been excluded, a warning is issued and no centers are changed. Nothing is done for a linked star with fewer than two 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.rad2deg([ _pixel_to_world_radians(star.wcs_large, *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 = _lonlat_to_pixel(star.wcs_large, mean_lon, mean_lat) star.cutout_center = np.asarray(pixel_center) - star.origin def constrain_fluxes(self): """ Constrain the fluxes of linked `EPSFStar` objects (i.e., the same physical star) to have the same value. Only `EPSFStar` objects that have not been excluded during the ePSF build process will be used to constrain the fluxes. The single flux is calculated as the mean of the fluxes of the linked stars. This assumes that the linked images have the same flux scale (e.g., the same exposure time and throughput). Averaging the fluxes across dithered images removes the pixel-phase dependence of the individual flux measurements caused by intra-pixel sensitivity variations, which would otherwise be absorbed into the ePSF (Anderson and King 2000, Godden and Blundell 2026). The ``flux`` of each nonexcluded star is updated in place. Excluded stars are left unchanged. If all of the stars have been excluded, a warning is issued and no fluxes are changed. Nothing is done for a linked star with fewer than two stars. """ if len(self._data) < 2: # no linked stars return if self.all_excluded: msg = ('Cannot constrain fluxes of linked stars because ' 'they have all been excluded during the ePSF ' 'build process.') warnings.warn(msg, AstropyUserWarning) return good_stars = self.all_good_stars mean_flux = np.mean([star.flux for star in good_stars]) for star in good_stars: star.flux = mean_flux def _lonlat_to_pixel(wcs, lon, lat): """ Convert a world longitude and latitude in degrees to pixel coordinates. The longitude and latitude are placed in the world axis order and units of the WCS, so a WCS whose latitude axis comes first (e.g., ``CTYPE1 = 'DEC--TAN'``) is handled correctly. 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`). lon, lat : float The world longitude and latitude in degrees. Returns ------- x, y : float The pixel coordinates. """ low_level_wcs = _low_level_wcs(wcs) # The second element of each world_axis_object_components entry # is the index of the axis within the sky coordinate, 0 for the # longitude and 1 for the latitude component_indices = [comp[1] for comp in low_level_wcs.world_axis_object_components] units = low_level_wcs.world_axis_units world = [None, None] for component, value in enumerate((lon, lat)): idx = component_indices.index(component) world[idx] = value * u.deg.to(units[idx]) return low_level_wcs.world_to_pixel_values(*world) def _compute_mean_sky_coordinate(sky_coords): """ Compute the mean sky coordinate using spherical trigonometry. This function properly handles coordinate system singularities by converting to Cartesian coordinates for averaging, then converting back to spherical coordinates. Parameters ---------- sky_coords : `~numpy.ndarray`, 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. Any `~numpy.ndarray` is wrapped in an `~astropy.nddata.NDData` object. Other list elements are returned unchanged. Parameters ---------- data : `~numpy.ndarray`, `~astropy.nddata.NDData`, or list of these The input data to normalize. Returns ------- data : list The normalized list of images. Raises ------ TypeError If the input data is not an array, an NDData object, or a list. """ if isinstance(data, (NDData, np.ndarray)): data = [data] elif not isinstance(data, list): msg = ('data must be a 2D array, an NDData object, or a list of ' '2D arrays or NDData objects') raise TypeError(msg) return [NDData(img) if isinstance(img, np.ndarray) else img for img in data] 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 does not have 2D data. """ for i, img in enumerate(data): if not isinstance(img, NDData): msg = (f'All data elements must be 2D arrays or NDData ' f'objects. Element {i} is {type(img)}') raise TypeError(msg) if img.data.ndim != 2: msg = (f'All input images must be 2D. The image at index ' f'{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 images, ' "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 images, ' 'each image must be an NDData object with 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 the corresponding # NDData object has a WCS 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 ' 'positions, the corresponding image must be an ' 'NDData object with 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). A star is not extracted, and a warning is issued, if its cutout bounds lie partially or completely outside the input ``data`` image, if its position or its catalog ``flux`` is not finite, or if its cutout is completely masked and no catalog ``flux`` is given. Parameters ---------- data : 2D `~numpy.ndarray`, `~astropy.nddata.NDData`, or list of these A 2D `~numpy.ndarray`, a `~astropy.nddata.NDData` object, or a list of 2D arrays and/or `~astropy.nddata.NDData` objects containing the 2D image(s) from which to extract the stars. A plain array carries no uncertainty or WCS, so its stars are given uniform weights and the star positions must be in pixel coordinates. If the array is a `~numpy.ma.MaskedArray`, its masked pixels are given zero weight. Use a `~astropy.nddata.NDData` object to input an uncertainty, mask, or WCS. If an `~astropy.nddata.NDData` object has an ``uncertainty``, it is converted to a standard deviation and the weights of each star are the inverse of the standard deviation. An uncertainty that cannot be converted (e.g., an `~astropy.nddata.UnknownUncertainty`, which `~astropy.nddata.NDData` creates for a plain array) is assumed to be a standard deviation. An uncertainty object whose ``uncertainty_type`` is ``'weights'`` is used directly as the weights. Nonfinite weights (e.g., from a zero uncertainty) are set to zero, and a warning is issued. Pixels that are masked in the ``mask`` attribute are given zero weight. If the input ``catalogs`` contain only the sky coordinates (i.e., not the pixel coordinates) of the stars then each image must be a `~astropy.nddata.NDData` object with a valid ``wcs`` attribute. Any unit of an input `~astropy.units.Quantity` or `~astropy.nddata.NDData` object is ignored. 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 are defined in sky coordinates. If a linked star can be extracted from only one of the input images, it is returned as a plain `EPSFStar` instead of a `LinkedEPSFStar`. 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 ``id_label`` of each extracted star is set to its table row number (starting at 1). The catalogs may also contain an optional ``flux`` column giving the flux of each star. If present, these values are used instead of estimating the flux from the cutout data. 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 An `EPSFStars` instance containing the extracted stars. Warns ----- AstropyUserWarning If any star is not extracted, if a cutout has nonfinite weights or nonfinite data values, if all unmasked values in a cutout are zero, or if an input catalog is empty. """ 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, n_overlap_failed = _extract_linked_stars( data, catalogs[0], size) else: # no linked stars stars_out, n_overlap_failed = _extract_unlinked_stars( data, catalogs, size) if n_overlap_failed > 0: msg = (f'{n_overlap_failed} star cutout(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. If there is more than one image, each must have a valid ``wcs`` attribute. catalog : `~astropy.table.Table` A single catalog of sources to be extracted from the input ``data``. If there is more than one image, the center of each source must be defined in sky coordinates (in a ``skycoord`` column containing a `~astropy.coordinates.SkyCoord` object). For a single image, pixel coordinates (in ``x`` and ``y`` columns) are used if present. 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. Stars that could not be extracted from any image are omitted. n_overlap_failed : int The number of star cutouts, summed over the images, 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] n_overlap_failed = 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, n_overlap_failed 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` 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` objects A list of `EPSFStar` instances containing the extracted stars. Failed extractions are omitted. n_overlap_failed : int The number of stars that failed extraction because their cutout region extended beyond the input image. """ stars_out = [] n_overlap_failed_total = 0 for img, cat in zip(data, catalogs, strict=True): extracted, n_overlap_failed = _extract_stars( img, cat, size=size, use_xy=True) stars_out.extend(extracted) n_overlap_failed_total += n_overlap_failed # Filter out None values return ([star for star in stars_out if star is not None], n_overlap_failed_total) 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`. n_overlap_failed : 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 if data_mask is np.ma.nomask: data_mask = None stars = [] n_nonfinite_weights = 0 n_nonfinite_positions = 0 n_nonfinite_fluxes = 0 n_overlap_failed = 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)): if not (np.isfinite(xcenter) and np.isfinite(ycenter)): stars.append(None) n_nonfinite_positions += 1 continue if (fluxes is not None and not np.isfinite(fluxes[i])): stars.append(None) n_nonfinite_fluxes += 1 continue try: large_slc, _ = overlap_slices(data.data.shape, size, (ycenter, xcenter), mode='strict') except (PartialOverlapError, NoOverlapError): stars.append(None) n_overlap_failed += 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: n_nonfinite_weights += 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). This mutates the process-global warning filters, # so it is safe only for this brief star-construction # window under single-threaded use. 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 star._has_all_zero_data: all_zero_stars.append((xcenter, ycenter)) except ValueError as exc: # Collect flux estimation failures and emit warnings later flux_failures.append((xcenter, ycenter, exc)) stars.append(None) # Emit consolidated warning for nonfinite positions if n_nonfinite_positions > 0: msg = (f'{n_nonfinite_positions} star(s) were not extracted ' 'because their (x, y) positions were not finite.') warnings.warn(msg, AstropyUserWarning) # Emit consolidated warning for nonfinite catalog fluxes if n_nonfinite_fluxes > 0: msg = (f'{n_nonfinite_fluxes} star(s) were not extracted ' 'because their catalog flux values were not finite.') warnings.warn(msg, AstropyUserWarning) # Emit consolidated warning for nonfinite weights if n_nonfinite_weights > 0: msg = (f'{n_nonfinite_weights} star cutout(s) had ' 'nonfinite 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 nonfinite weights (data then becomes # completely masked), so we emit them after the nonfinite 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, n_overlap_failed 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' or type='uncertainty') The weight array (type='weights') or the standard deviation array (type='uncertainty') from the input data. """ if data.uncertainty is None: return {'type': 'none'} if data.uncertainty.uncertainty_type == 'weights': return { 'type': 'weights', 'array': data.uncertainty.array, } # For other uncertainties, convert the full array to standard # deviation once so that cutouts only need to be sliced. An # uncertainty that cannot be converted (e.g., UnknownUncertainty, # which NDData creates for a plain array) is taken to be a standard # deviation. uncertainty = data.uncertainty with np.errstate(divide='ignore', invalid='ignore'): try: stddev_array = uncertainty.represent_as(StdDevUncertainty).array except (AttributeError, TypeError): stddev_array = uncertainty.array return { 'type': 'uncertainty', 'array': stddev_array, } 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 nonfinite 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': # Copy so that modifications below (e.g., masking) do not # propagate back to the user's input array weights_cutout = np.array( uncertainty_info['array'][slices], dtype=float) else: # Convert the standard deviation cutout to weights with np.errstate(divide='ignore', invalid='ignore'): weights_cutout = 1.0 / uncertainty_info['array'][slices] # Check for nonfinite weights and track if found has_nonfinite = not np.all(np.isfinite(weights_cutout)) if has_nonfinite: # Set nonfinite 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-24be529/photutils/psf/flags.py000066400000000000000000000252061526452531300221060ustar00rootroot00000000000000# 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 typing import ClassVar from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._flags import (FlagDefinition, FlagRegistry, decode_flags, update_flag_docstring) __all__ = ['PSF_FLAGS', 'decode_psf_flags'] class _PSFFlags(FlagRegistry): """ 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 nonconvergence' """ # Define all PSF flags with their properties FLAG_DEFINITIONS: ClassVar = [ FlagDefinition( 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.'), ), FlagDefinition( bit_value=2, name='position_outside_image', description='fitted position outside input image bounds', detailed_description=('The fitted source position is outside the ' 'bounds of the input image.'), ), FlagDefinition( bit_value=4, name='non_positive_flux', description='nonpositive flux', detailed_description=('The fitted flux value is negative or zero, ' 'which is nonphysical.'), ), FlagDefinition( bit_value=8, name='no_convergence', description='possible nonconvergence', detailed_description=('The PSF fitting algorithm may not have ' 'converged to a stable solution.'), ), FlagDefinition( bit_value=16, name='no_covariance', description='missing parameter covariance', detailed_description=('Parameter covariance matrix is not ' 'available, preventing error estimation.'), ), FlagDefinition( bit_value=32, name='parameter_near_bound', description='fitted parameter near a bound', detailed_description=('One or more fitted parameters are very ' 'close to their imposed bounds.'), ), FlagDefinition( bit_value=64, name='no_overlap', description='no overlap with data', detailed_description=('The source fitting region has no overlap ' 'with the input image.'), ), FlagDefinition( bit_value=128, name='fully_masked', description='fully masked source', detailed_description=('All pixels in the source fitting region ' 'are masked.'), ), FlagDefinition( bit_value=256, name='too_few_pixels', description='too few pixels for fitting', detailed_description=('Insufficient unmasked pixels available ' 'for reliable PSF fitting.'), ), FlagDefinition( bit_value=512, name='non_finite_position', description='nonfinite fitted position', detailed_description=('The fitted x or y position is NaN or inf, ' 'indicating an invalid or failed fit.'), ), FlagDefinition( bit_value=1024, name='non_finite_flux', description='nonfinite fitted flux', detailed_description=('The fitted flux value is NaN or inf, ' 'indicating an invalid or failed fit.'), ), FlagDefinition( bit_value=2048, name='non_finite_local_bkg', description='nonfinite local background', detailed_description=('The local background value is NaN or ' 'inf, so it was not subtracted before ' 'fitting.'), ), FlagDefinition( bit_value=4096, name='position_outside_fit_region', description='fitted position outside the fitted data region', detailed_description=('The fitted source position is outside ' 'the region of fitted data, which is the ' 'fit_shape box around the initial ' 'position, trimmed to the input image. ' 'The fit is constrained only by the PSF ' 'wings, often because it moved toward a ' 'neighboring source. For grouped fits, ' 'the region is the box of each source, ' 'not the union of the boxes of the ' 'group.'), ), ] domain: ClassVar = 'psf' # Remove in 4.0 _DEPRECATED_FLAG_NAMES: ClassVar = { 'npixfit_partial': 'n_pixels_fit_partial', 'outside_bounds': 'position_outside_image', 'non_finite_localbkg': 'non_finite_local_bkg', 'negative_flux': 'non_positive_flux', 'near_bound': 'parameter_near_bound', } _DEPRECATED_SINCE: ClassVar = { 'npixfit_partial': '3.0', 'outside_bounds': '3.1', 'non_finite_localbkg': '3.1', 'negative_flux': '3.1', 'near_bound': '3.1', } _DEPRECATED_UNTIL: ClassVar = '4.0' # Create a singleton instance for global use PSF_FLAGS = _PSFFlags() def _update_decode_docstring(func): """ Decorator to update a function docstring with the PSF flag documentation. The ```` placeholder in the function docstring is replaced with a bullet list generated from ``PSF_FLAGS`` (see `photutils.utils._flags.update_flag_docstring`). Parameters ---------- func : function The function to decorate. Returns ------- func : function The decorated function with updated docstring. """ return update_flag_docstring(func, PSF_FLAGS, indent=None) @_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 the names of the flags that are set. 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 names (strings). Default is `False`. Returns ------- decoded : list of str, list of int, or nested list List of active flag names (or bit values) for a scalar input. For an array input, a nested list with the same shape as the input is returned, where each innermost element is the list of active flag names (or bit values) for the corresponding flag. 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 flags 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', 'non_positive_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.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: non_positive_flux Source 3: n_pixels_fit_partial, no_covariance, too_few_pixels, \ non_finite_position, non_finite_flux """ return decode_flags(flags, PSF_FLAGS, return_bit_values=return_bit_values) astropy-photutils-24be529/photutils/psf/functional_models.py000066400000000000000000003534071526452531300245260ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Functional PSF models. """ from functools import lru_cache 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, j0, j1, jn_zeros, ndtr, owens_t __all__ = [ 'AiryDiskPRF', 'AiryDiskPSF', 'CircularGaussianPRF', 'CircularGaussianPSF', 'CircularGaussianSigmaPRF', 'GaussianPRF', 'GaussianPSF', 'MoffatPRF', 'MoffatPSF', ] # The smallest positive normal float32 value (~1.2e-38), used as the # lower bound of the strictly-positive model parameters. _FLOAT_TINY = float(np.finfo(np.float32).tiny) # Conversion factor from a Gaussian FWHM to its standard deviation. _GAUSSIAN_FWHM_TO_SIGMA = 1.0 / (2.0 * np.sqrt(2.0 * np.log(2.0))) def _dimensionless_value(value): """ Return the value of a dimensionless `~astropy.units.Quantity` as a plain array, and any other input unchanged. """ if isinstance(value, u.Quantity): return value.to_value(u.dimensionless_unscaled) return value def _rotated_gaussian_moments(x_sigma, y_sigma, theta): """ Calculate the moments along the x- and y-axes of a rotated 2D Gaussian. Parameters ---------- x_sigma, y_sigma : float or `~numpy.ndarray` The standard deviations along the principal axes of the Gaussian. theta : float or `~astropy.units.Quantity` The counterclockwise rotation angle, either as a float in radians or as an angular `~astropy.units.Quantity`. Returns ------- cost, sint : float or `~numpy.ndarray` The cosine and sine of the rotation angle. x_std, y_std : float or `~numpy.ndarray` The standard deviations along the x- and y-axes. rho : float or `~numpy.ndarray` The correlation coefficient. sqrt_one_minus_rho2 : float or `~numpy.ndarray` The square root of ``1 - rho**2``. """ cost = _dimensionless_value(np.cos(theta)) sint = _dimensionless_value(np.sin(theta)) x_std = np.hypot(cost * x_sigma, sint * y_sigma) y_std = np.hypot(sint * x_sigma, cost * y_sigma) std_product = x_std * y_std rho = _dimensionless_value( cost * sint * (x_sigma**2 - y_sigma**2) / std_product) # The determinant of the covariance matrix is invariant under # rotation, which gives sqrt(1 - rho**2) without loss of precision # for a highly elongated Gaussian. sqrt_one_minus_rho2 = _dimensionless_value( x_sigma * y_sigma / std_product) return cost, sint, x_std, y_std, rho, sqrt_one_minus_rho2 def _bivariate_normal_corner_term(h, k, rho, sqrt_one_minus_rho2): """ Calculate the part of the standard bivariate normal distribution function that does not cancel in the probability of a rectangle. The distribution function with a correlation coefficient ``rho`` is ``(ndtr(h) + ndtr(k)) / 2`` minus the returned term, where the term is written with Owen's T function [1]_. The ``ndtr`` terms cancel when the distribution function is differenced over the four corners of a rectangle. Parameters ---------- h, k : float or `~numpy.ndarray` The standardized coordinates. rho : float or `~numpy.ndarray` The correlation coefficient. sqrt_one_minus_rho2 : float or `~numpy.ndarray` The square root of ``1 - rho**2``. Returns ------- result : `~numpy.ndarray` The term to subtract from ``(ndtr(h) + ndtr(k)) / 2`` to get the distribution function. References ---------- .. [1] Owen, D. B. 1956, Annals of Mathematical Statistics, 27, 1075 """ h, k, rho, sqrt_one_minus_rho2 = np.broadcast_arrays( h, k, rho, sqrt_one_minus_rho2) with np.errstate(divide='ignore', invalid='ignore'): slope_h = (k - rho * h) / (h * sqrt_one_minus_rho2) slope_k = (h - rho * k) / (k * sqrt_one_minus_rho2) # The slopes take their limiting values where a coordinate is zero. slope_h = np.where(h == 0, np.copysign(np.inf, k), slope_h) slope_k = np.where(k == 0, np.copysign(np.inf, h), slope_k) product = h * k opposite_signs = (product < 0) | ((product == 0) & (h + k < 0)) term = owens_t(h, slope_h) + owens_t(k, slope_k) + 0.5 * opposite_signs # Both slopes are undefined at the origin, where the distribution # function is 1/4 + arcsin(rho) / (2 pi). origin = (h == 0) & (k == 0) return np.where(origin, 0.25 - np.arcsin(rho) / (2.0 * np.pi), term) def _is_separable(rho): """ Return whether a 2D Gaussian with the input correlation coefficient is separable along the x- and y-axes. """ return np.all(np.abs(rho) < 1.0e-12) def _pixel_lattice(dx, dy): """ Find the grid of pixel corners shared by pixels that lie on a lattice with a spacing of one pixel. Parameters ---------- dx, dy : float or `~numpy.ndarray` The x and y pixel centers. Returns ------- result : tuple or `None` The x and y positions of the pixel corners along each axis of the corner grid and the x and y integer indices in that grid of the lower corner of each pixel. `None` is returned if the pixels are not on a lattice with a spacing of one pixel or if the grid would have more corners than twice the number of pixels, in which case the grid saves no evaluations. """ dx, dy = np.broadcast_arrays(dx, dy) indices = [] edges = [] n_corners = 1.0 for offset in (dx, dy): lowest = offset.min() highest = offset.max() if not (np.isfinite(lowest) and np.isfinite(highest)): return None steps = offset - lowest index = np.rint(steps) # The offsets are differences from the source position, so # they are on the lattice only to within their rounding tolerance = 8.0 * np.spacing(max(abs(lowest), abs(highest), 1.0)) if not np.all(np.abs(steps - index) <= tolerance): return None n_edges = index.max() + 2.0 n_corners *= n_edges if n_corners > 2.0 * offset.size: return None indices.append(index.astype(np.intp)) edges.append(lowest - 0.5 + np.arange(n_edges)) return (*edges, *indices) def _gaussian_pixel_fraction(dx, dy, x_std, y_std, rho, sqrt_one_minus_rho2): """ Calculate the fraction of the flux of a 2D Gaussian that falls in a pixel. Parameters ---------- dx, dy : float or `~numpy.ndarray` The x and y pixel centers relative to the Gaussian center. x_std, y_std : float or `~numpy.ndarray` The standard deviations of the Gaussian along the x- and y-axes. rho : float or `~numpy.ndarray` The correlation coefficient of the Gaussian. sqrt_one_minus_rho2 : float or `~numpy.ndarray` The square root of ``1 - rho**2``. Returns ------- result : `~numpy.ndarray` The fraction of the flux in each pixel. """ x_lo = (dx - 0.5) / x_std x_hi = (dx + 0.5) / x_std y_lo = (dy - 0.5) / y_std y_hi = (dy + 0.5) / y_std if _is_separable(rho): sqrt2 = np.sqrt(2.0) return (0.25 * (erf(x_hi / sqrt2) - erf(x_lo / sqrt2)) * (erf(y_hi / sqrt2) - erf(y_lo / sqrt2))) args = (rho, sqrt_one_minus_rho2) single_gaussian = all(np.size(value) == 1 for value in (x_std, y_std, rho)) if single_gaussian and (lattice := _pixel_lattice(dx, dy)) is not None: # Neighboring pixels share their corners, so each corner of # the grid is evaluated once instead of once for each of the # pixels that touch it. x_edges, y_edges, x_index, y_index = lattice corners = _bivariate_normal_corner_term( x_edges / np.ravel(x_std), y_edges[:, np.newaxis] / np.ravel(y_std), np.ravel(rho), np.ravel(sqrt_one_minus_rho2)) fraction = (corners[y_index + 1, x_index] + corners[y_index, x_index + 1] - corners[y_index + 1, x_index + 1] - corners[y_index, x_index]) fraction = fraction.reshape(np.shape(x_lo)) else: fraction = (_bivariate_normal_corner_term(x_lo, y_hi, *args) + _bivariate_normal_corner_term(x_hi, y_lo, *args) - _bivariate_normal_corner_term(x_hi, y_hi, *args) - _bivariate_normal_corner_term(x_lo, y_lo, *args)) # Rounding in the difference can give values of about -1e-16 far # from the peak return np.maximum(fraction, 0.0) def _gaussian_edge_integrals(edge, lo, hi, std, rho, sqrt_one_minus_rho2): """ Integrate a 2D Gaussian of unit flux, and its derivative across a pixel edge, along that pixel edge. Parameters ---------- edge : float or `~numpy.ndarray` The position of the pixel edge along the axis perpendicular to it, relative to the Gaussian center and in units of the standard deviation along that axis. lo, hi : float or `~numpy.ndarray` The limits of the pixel edge along the other axis, relative to the Gaussian center and in units of the standard deviation along that axis. std : float or `~numpy.ndarray` The standard deviation of the Gaussian along the axis perpendicular to the edge. rho : float or `~numpy.ndarray` The correlation coefficient of the Gaussian. sqrt_one_minus_rho2 : float or `~numpy.ndarray` The square root of ``1 - rho**2``. Returns ------- integral, deriv_integral : `~numpy.ndarray` The integral along the edge of the Gaussian and of the derivative of the Gaussian with respect to the coordinate perpendicular to the edge. """ # Along the edge the Gaussian is a 1D Gaussian with a mean of # rho * edge and a standard deviation of sqrt(1 - rho**2). t_lo = (lo - rho * edge) / sqrt_one_minus_rho2 t_hi = (hi - rho * edge) / sqrt_one_minus_rho2 norm = 1.0 / np.sqrt(2.0 * np.pi) density = norm * np.exp(-0.5 * edge**2) enclosed = ndtr(t_hi) - ndtr(t_lo) density_diff = norm * (np.exp(-0.5 * t_hi**2) - np.exp(-0.5 * t_lo**2)) integral = density * enclosed / std deriv_integral = (-density * (edge * enclosed + rho / sqrt_one_minus_rho2 * density_diff) / std**2) return integral, deriv_integral def _separable_axis_terms(lo, hi, std): """ Calculate the terms along one axis of the derivatives of the pixel fraction of a 2D Gaussian that is separable along the x- and y-axes. Parameters ---------- lo, hi : float or `~numpy.ndarray` The pixel edges along the axis, relative to the Gaussian center and in units of the standard deviation along that axis. std : float or `~numpy.ndarray` The standard deviation of the Gaussian along the axis. Returns ------- fraction, d_center, d_var, density_diff : `~numpy.ndarray` The fraction of the 1D Gaussian between the pixel edges, its partial derivatives with respect to the center and the variance, and the difference of the 1D Gaussian between the pixel edges. """ norm = 1.0 / np.sqrt(2.0 * np.pi) density_lo = norm * np.exp(-0.5 * lo**2) / std density_hi = norm * np.exp(-0.5 * hi**2) / std fraction = ndtr(hi) - ndtr(lo) d_center = density_lo - density_hi d_var = 0.5 * (lo * density_lo - hi * density_hi) / std return fraction, d_center, d_var, density_hi - density_lo def _gaussian_pixel_fraction_derivs(x_lo, x_hi, y_lo, y_hi, x_std, y_std, rho, sqrt_one_minus_rho2): """ Calculate the partial derivatives of the fraction of the flux of a 2D Gaussian that falls in a pixel. The derivatives with respect to the elements of the covariance matrix follow from the Gaussian being a solution of the diffusion equation. The derivative of the Gaussian with respect to a variance is half of its second derivative along that axis, and the derivative with respect to the covariance is its mixed second derivative. The integrals of those derivatives over the pixel reduce to integrals along the pixel edges and to the values of the Gaussian at the pixel corners. Parameters ---------- x_lo, x_hi, y_lo, y_hi : float or `~numpy.ndarray` The pixel edges relative to the Gaussian center, in units of the standard deviations of the Gaussian along the x- and y-axes. x_std, y_std : float or `~numpy.ndarray` The standard deviations of the Gaussian along the x- and y-axes. rho : float or `~numpy.ndarray` The correlation coefficient of the Gaussian. sqrt_one_minus_rho2 : float or `~numpy.ndarray` The square root of ``1 - rho**2``. Returns ------- d_x_0, d_y_0, d_var_x, d_var_y, d_cov_xy : `~numpy.ndarray` The partial derivatives of the pixel fraction with respect to the x and y positions of the Gaussian center, the variances along the x- and y-axes, and the covariance. """ if _is_separable(rho): # Each derivative is a product of terms along the two axes, # which is much faster to evaluate than the edge integrals. x_frac, x_d_center, x_d_var, x_diff = _separable_axis_terms( x_lo, x_hi, x_std) y_frac, y_d_center, y_d_var, y_diff = _separable_axis_terms( y_lo, y_hi, y_std) return (x_d_center * y_frac, x_frac * y_d_center, x_d_var * y_frac, x_frac * y_d_var, x_diff * y_diff) args = (rho, sqrt_one_minus_rho2) x_lo_int, x_lo_deriv = _gaussian_edge_integrals(x_lo, y_lo, y_hi, x_std, *args) x_hi_int, x_hi_deriv = _gaussian_edge_integrals(x_hi, y_lo, y_hi, x_std, *args) y_lo_int, y_lo_deriv = _gaussian_edge_integrals(y_lo, x_lo, x_hi, y_std, *args) y_hi_int, y_hi_deriv = _gaussian_edge_integrals(y_hi, x_lo, x_hi, y_std, *args) d_x_0 = x_lo_int - x_hi_int d_y_0 = y_lo_int - y_hi_int d_var_x = 0.5 * (x_hi_deriv - x_lo_deriv) d_var_y = 0.5 * (y_hi_deriv - y_lo_deriv) def corner_density(h, k): exponent = (h**2 - 2.0 * rho * h * k + k**2) / sqrt_one_minus_rho2**2 return np.exp(-0.5 * exponent) d_cov_xy = ((corner_density(x_hi, y_hi) - corner_density(x_lo, y_hi) - corner_density(x_hi, y_lo) + corner_density(x_lo, y_lo)) / (2.0 * np.pi * x_std * y_std * sqrt_one_minus_rho2)) return d_x_0, d_y_0, d_var_x, d_var_y, d_cov_xy def _circular_gaussian_prf_derivs(x, y, flux, x_0, y_0, sigma): """ Calculate the partial derivatives of a circular 2D Gaussian integrated over pixels. 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. sigma : float The standard deviation of the Gaussian. Returns ------- result : list of `~numpy.ndarray` The partial derivatives with respect to the flux, the x and y positions, and the standard deviation. """ # The Gaussian is separable, so the pixel fraction is the product # of the fractions along each axis. norm = 1.0 / np.sqrt(2.0 * np.pi) sqrt2 = np.sqrt(2.0) fractions = [] for offset in (x - x_0, y - y_0): lo = (offset - 0.5) / sigma hi = (offset + 0.5) / sigma density_lo = norm * np.exp(-0.5 * lo**2) density_hi = norm * np.exp(-0.5 * hi**2) fraction = 0.5 * (erf(hi / sqrt2) - erf(lo / sqrt2)) d_center = (density_lo - density_hi) / sigma d_sigma = (lo * density_lo - hi * density_hi) / sigma fractions.append((fraction, d_center, d_sigma)) (x_frac, x_d_center, x_d_sigma), (y_frac, y_d_center, y_d_sigma) = ( fractions) return [x_frac * y_frac, flux * x_d_center * y_frac, flux * x_frac * y_d_center, flux * (x_d_sigma * y_frac + x_frac * y_d_sigma)] @lru_cache def _pixel_quadrature_nodes(n_nodes): """ Return the Gauss-Legendre nodes and weights for the integral over a pixel of unit width along one axis. Parameters ---------- n_nodes : int The number of nodes. Returns ------- offsets, weights : `~numpy.ndarray` The offsets of the nodes from the pixel center and their weights, which sum to 1. The arrays are read-only because they are cached and shared. """ nodes, weights = np.polynomial.legendre.leggauss(n_nodes) offsets = 0.5 * nodes weights = 0.5 * weights offsets.setflags(write=False) weights.setflags(write=False) return offsets, weights # The largest number of elements in the arrays of quadrature node # coordinates for which all of the nodes are evaluated in a single call _MAX_QUADRATURE_ELEMENTS = 2**20 def _integrate_over_pixels(func, x, y, params, n_nodes): """ Integrate a function over the pixels of unit area centered at the input positions using Gauss-Legendre quadrature. Parameters ---------- func : callable The function to integrate. It is called as ``func(x, y)`` with arrays that broadcast to two leading axes for the quadrature nodes along the y- and x-axes. It must return an array, or a list of arrays, with those leading axes. x, y : float or array_like The x and y coordinates of the pixel centers. params : sequence The model parameter values used by ``func``. Their shapes are needed to place the quadrature axis ahead of every axis of a parameter array, e.g., the model axis of a model set. n_nodes : int The number of quadrature nodes along each axis of a pixel. Returns ------- result : `~numpy.ndarray` or list of `~numpy.ndarray` The integral of each array returned by ``func`` over each pixel. """ shape = np.broadcast_shapes(np.shape(x), np.shape(y), *(np.shape(param) for param in params)) x = np.broadcast_to(x, shape, subok=True) y = np.broadcast_to(y, shape, subok=True) offsets, weights = _pixel_quadrature_nodes(n_nodes) trailing = (1,) * len(shape) x_offsets = offsets.reshape((n_nodes, *trailing)) y_offsets = offsets.reshape((n_nodes, 1, *trailing)) if isinstance(x, u.Quantity): x_offsets = x_offsets << x.unit y_offsets = y_offsets << y.unit xsub = x + x_offsets weights_2d = np.outer(weights, weights).reshape((n_nodes, n_nodes, *trailing)) # Small inputs, e.g., the cutouts used for fitting, are evaluated at # all of the nodes in one call, which is the fastest. Large inputs # are evaluated one row of nodes at a time so that the temporary # arrays are n_nodes, and not n_nodes**2, times the size of the # input. step = n_nodes if xsub.size * n_nodes <= _MAX_QUADRATURE_ELEMENTS else 1 result = None for start in range(0, n_nodes, step): rows = slice(start, start + step) values = func(xsub, y + y_offsets[rows]) is_list = isinstance(values, list) if not is_list: values = [values] terms = [np.sum(value * weights_2d[rows], axis=(0, 1)) for value in values] # Not an in-place addition because the terms are NumPy scalars # for scalar inputs if result is None: result = terms else: result = [total + term for total, term in zip(result, terms, strict=True)] return result if is_list else result[0] def _validate_n_nodes(n_nodes): """ Validate the number of quadrature nodes along each axis of a pixel. """ if (isinstance(n_nodes, bool) or not isinstance(n_nodes, (int, np.integer)) or n_nodes < 1): msg = 'n_nodes must be a positive integer' raise ValueError(msg) return int(n_nodes) class _PixelQuadratureMixin: """ Mixin class for the models that are integrated over the pixels with Gauss-Legendre quadrature. """ @property def n_nodes(self): """ The number of Gauss-Legendre quadrature nodes along each axis of a pixel. """ return self._n_nodes @n_nodes.setter def n_nodes(self, value): """ Set the number of quadrature nodes along each axis of a pixel. Parameters ---------- value : int The number of nodes, which must be a positive integer. """ self._n_nodes = _validate_n_nodes(value) 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. x_fwhm : float or `~astropy.units.Quantity`, optional The full width at half maximum (FWHM) of the Gaussian along its x-axis before the rotation by ``theta``. y_fwhm : float or `~astropy.units.Quantity`, optional FWHM of the Gaussian along its y-axis before the rotation by ``theta``. theta : float or `~astropy.units.Quantity`, optional The counterclockwise rotation angle, either as a float in degrees or as an angular `~astropy.units.Quantity`. 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, CircularGaussianSigmaPRF, MoffatPSF, AiryDiskPSF 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 the standard deviations along the principal axes of the Gaussian, which are the x- and y-axes when :math:`\theta = 0`. .. 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 its principal 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. These bounds apply only during fitting. Directly setting a nonpositive width produces unphysical model values. This model is evaluated at the input coordinates and is not integrated over the detector pixels. Its values on a grid of detector pixels are the values of the PSF at the pixel centers, not the fluxes in the pixels. For a PSF that is undersampled by the detector pixels, the model is sharper than a source in the data and the sum of its values over the pixels depends on the subpixel position of the source. This model should therefore not be used to fit the pixel values of an image, e.g., for PSF photometry. If its shape parameters are fixed to those of the PSF before the integration over the pixels, the fitted flux is biased by a few percent for a FWHM of 2 to 3 pixels, and by much more, with an error that depends on the subpixel position of the source, for a FWHM of less than about 1.5 pixels. Use `GaussianPRF` instead, which is integrated over the pixels. 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_TINY, None), fixed=True, description='FWHM of the Gaussian along its x-axis') y_fwhm = Parameter( default=1, bounds=(_FLOAT_TINY, None), fixed=True, description='FWHM of the Gaussian along its y-axis') theta = Parameter( default=0.0, description=('CCW rotation angle either as a float (in ' 'degrees) or a Quantity angle'), 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 before the rotation by ``theta``. """ return self.x_fwhm * _GAUSSIAN_FWHM_TO_SIGMA @property def y_sigma(self): """ Gaussian sigma (standard deviation) along the y-axis before the rotation by ``theta``. """ 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 # A float theta is in degrees, but ellipse_extent expect radians if self.theta.unit is None: theta = np.deg2rad(self.theta.value) else: theta = self.theta.quantity dx, dy = ellipse_extent(a, b, 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 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 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 its x- and y-axes before the rotation by ``theta``. theta : float or `~astropy.units.Quantity` The counterclockwise rotation angle, either as a float in degrees or as an angular `~astropy.units.Quantity`. 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 its x- and y-axes before the rotation by ``theta``. theta : float or `~astropy.units.Quantity` The counterclockwise rotation angle, either as a float in degrees or as an angular `~astropy.units.Quantity`. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. The derivative with respect to ``theta`` is always per degree, even when ``theta`` is input as an angular `~astropy.units.Quantity`. """ 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)) # Compute the flux derivative directly from the exponential # term so that it is finite even at flux = 0 dg_dflux = exp / (2 * np.pi * xstd * ystd) g = flux * dg_dflux 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_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, so 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): # 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. fwhm : float or `~astropy.units.Quantity`, 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, CircularGaussianSigmaPRF, MoffatPSF, AiryDiskPSF 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. 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. This bound applies only during fitting. Directly setting a nonpositive width produces unphysical model values. This model is evaluated at the input coordinates and is not integrated over the detector pixels. Its values on a grid of detector pixels are the values of the PSF at the pixel centers, not the fluxes in the pixels. For a PSF that is undersampled by the detector pixels, the model is sharper than a source in the data and the sum of its values over the pixels depends on the subpixel position of the source. This model should therefore not be used to fit the pixel values of an image, e.g., for PSF photometry. If its shape parameters are fixed to those of the PSF before the integration over the pixels, the fitted flux is biased by a few percent for a FWHM of 2 to 3 pixels, and by much more, with an error that depends on the subpixel position of the source, for a FWHM of less than about 1.5 pixels. Use `CircularGaussianPRF` instead, which is integrated over the pixels. 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_TINY, 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 deviation (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 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 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. """ derivs = GaussianPSF.fit_deriv(x, y, flux, x_0, y_0, fwhm, fwhm, 0.0) # The x and y FWHMs are the same variable for a circular # Gaussian, so the chain rule gives the sum of the two partial # derivatives. The theta derivative is dropped because theta is # not a parameter of this model. dg_dfwhm = derivs[3] + derivs[4] return [*derivs[:3], dg_dfwhm] @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): # The radial distance requires x and y to have the same unit. 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]], '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 area of a pixel centered at each input position (see Notes), and is equivalent to assuming the PSF is a 2D Gaussian at a *subpixel* level. The integral is exact for any rotation angle. 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. x_fwhm : float or `~astropy.units.Quantity`, optional The full width at half maximum (FWHM) of the Gaussian along its x-axis before the rotation by ``theta``. y_fwhm : float or `~astropy.units.Quantity`, optional FWHM of the Gaussian along its y-axis before the rotation by ``theta``. theta : float or `~astropy.units.Quantity`, optional The counterclockwise rotation angle, either as a float in degrees or as an angular `~astropy.units.Quantity`. 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, CircularGaussianSigmaPRF, MoffatPSF, AiryDiskPSF, MoffatPRF, AiryDiskPRF Notes ----- The model is the integral of a 2D Gaussian over a pixel of unit area that is aligned with the x- and y-axes and centered at :math:`(x, y)`: .. math:: f(x, y) = \int_{y - 0.5}^{y + 0.5} \int_{x - 0.5}^{x + 0.5} g(u, v) \,du \,dv where the Gaussian is: .. math:: g(u, v) = \frac{F}{2 \pi \sigma_{x} \sigma_{y}} \exp \left( -\frac{u^{\prime 2}}{2 \sigma_{x}^{2}} - \frac{v^{\prime 2}}{2 \sigma_{y}^{2}} \right) .. math:: u^\prime = (u - x_0) \cos(\theta) + (v - y_0) \sin(\theta) v^\prime = -(u - x_0) \sin(\theta) + (v - y_0) \cos(\theta) :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, :math:`\sigma_{x}` and :math:`\sigma_{y}` are the standard deviations along the principal axes of the Gaussian, and :math:`\theta` is the rotation angle of the Gaussian. For :math:`\theta = 0` the integral is a product of error functions: .. math:: f(x, y) = \frac{F}{4} \left[ {\rm erf} \left( \frac{x - x_0 + 0.5}{\sqrt{2} \sigma_{x}} \right) - {\rm erf} \left( \frac{x - x_0 - 0.5}{\sqrt{2} \sigma_{x}} \right) \right] \left[ {\rm erf} \left( \frac{y - y_0 + 0.5}{\sqrt{2} \sigma_{y}} \right) - {\rm erf} \left( \frac{y - y_0 - 0.5}{\sqrt{2} \sigma_{y}} \right) \right] A rotated Gaussian is not separable along the x- and y-axes. Its integral over the pixel is the probability of a rectangle for a bivariate normal distribution with a nonzero correlation coefficient, which is evaluated with Owen's T function [2]_. That evaluation is about four times slower than the product of error functions (roughly 0.2 ms instead of 0.05 ms for a 25 x 25 pixel stamp). The product of error functions is used whenever the correlation coefficient is zero, which is the case when ``theta`` is a multiple of 90 degrees or the x and y widths are equal. The rotated evaluation needs the bivariate normal distribution function at the four corners of each pixel. Input positions on a grid with a spacing of one pixel share those corners between neighboring pixels, so each corner is evaluated only once. Other input positions are about ten times slower than the product of error functions. The FWHMs of the Gaussian along its principal 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 Because the model is integrated over the pixels, its values on a grid with a spacing of one pixel also sum to the total flux, for any subpixel position of the source: .. math:: \sum_{i=-\infty}^{\infty} \sum_{j=-\infty}^{\infty} f(x + i, y + j) = 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. These bounds apply only during fitting. Directly setting a nonpositive width produces unphysical model values. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function .. [2] Owen, D. B. 1956, Annals of Mathematical Statistics, 27, 1075 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_TINY, None), fixed=True, description='FWHM of the Gaussian along its x-axis') y_fwhm = Parameter( default=1, bounds=(_FLOAT_TINY, None), fixed=True, description='FWHM of the Gaussian along its y-axis') theta = Parameter( default=0.0, description=('CCW rotation angle either as a float (in ' 'degrees) or a Quantity angle'), 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 before the integration over the pixels. """ return _gaussian_amplitude(self.flux, self.x_sigma, self.y_sigma) @property def x_sigma(self): """ Gaussian sigma (standard deviation) along the x-axis before the rotation by ``theta``. """ return self.x_fwhm * _GAUSSIAN_FWHM_TO_SIGMA @property def y_sigma(self): """ Gaussian sigma (standard deviation) along the y-axis before the rotation by ``theta``. """ 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 # A float theta is in degrees, but ellipse_extent expect radians if self.theta.unit is None: theta = np.deg2rad(self.theta.value) else: theta = self.theta.quantity dx, dy = ellipse_extent(a, b, 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 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 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 pixel-integrated 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 its x- and y-axes before the rotation by ``theta``. theta : float or `~astropy.units.Quantity` The counterclockwise rotation angle, either as a float in degrees or as an angular `~astropy.units.Quantity`. 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 _, _, x_std, y_std, rho, sqrt_one_minus_rho2 = ( _rotated_gaussian_moments(x_sigma, y_sigma, theta)) dx = x - x_0 dy = y - y_0 if has_units := isinstance(dx, u.Quantity): # A pixel has a size of one in the units of the positions x_std = x_std.to_value(dx.unit) y_std = y_std.to_value(dy.unit) dx = dx.value dy = dy.value fraction = _gaussian_pixel_fraction(dx, dy, x_std, y_std, rho, sqrt_one_minus_rho2) if has_units: # Inputs with units give an output with units fraction <<= u.dimensionless_unscaled return flux * fraction @staticmethod def fit_deriv(x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta): """ Calculate the partial derivatives of the pixel-integrated 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 its x- and y-axes before the rotation by ``theta``. theta : float or `~astropy.units.Quantity` The counterclockwise rotation angle, either as a float in degrees or as an angular `~astropy.units.Quantity`. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. The derivative with respect to ``theta`` is always per degree, even when ``theta`` is input as an angular `~astropy.units.Quantity`. """ 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 cost, sint, x_std, y_std, rho, sqrt_one_minus_rho2 = ( _rotated_gaussian_moments(x_sigma, y_sigma, theta)) cov_xy = cost * sint * (x_sigma**2 - y_sigma**2) dx = x - x_0 dy = y - y_0 edges = ((dx - 0.5) / x_std, (dx + 0.5) / x_std, (dy - 0.5) / y_std, (dy + 0.5) / y_std) fraction = _gaussian_pixel_fraction(dx, dy, x_std, y_std, rho, sqrt_one_minus_rho2) d_x_0, d_y_0, d_var_x, d_var_y, d_cov_xy = ( _gaussian_pixel_fraction_derivs(*edges, x_std, y_std, rho, sqrt_one_minus_rho2)) # Chain rule from the covariance matrix elements to the standard # deviations along the principal axes and the angle d_x_sigma = 2.0 * x_sigma * (cost**2 * d_var_x + sint**2 * d_var_y + cost * sint * d_cov_xy) d_y_sigma = 2.0 * y_sigma * (sint**2 * d_var_x + cost**2 * d_var_y - cost * sint * d_cov_xy) d_theta = (2.0 * cov_xy * (d_var_y - d_var_x) + ((cost**2 - sint**2) * (x_sigma**2 - y_sigma**2) * d_cov_xy)) # Chain rule for change of variables from sigma to fwhm d_x_fwhm = flux * d_x_sigma * _GAUSSIAN_FWHM_TO_SIGMA d_y_fwhm = flux * d_y_sigma * _GAUSSIAN_FWHM_TO_SIGMA # Chain rule for unit change # theta[rad] => theta[deg] * pi / 180, so drad/dtheta = pi / 180 d_theta = flux * d_theta * np.pi / 180.0 return [fraction, flux * d_x_0, flux * d_y_0, d_x_fwhm, d_y_fwhm, d_theta] @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): # 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 a 2D Gaussian at a *subpixel* 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. fwhm : float or `~astropy.units.Quantity`, 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, CircularGaussianPSF, GaussianPRF, CircularGaussianSigmaPRF, MoffatPSF, AiryDiskPSF, MoffatPRF, AiryDiskPRF Notes ----- The model is the integral of a circular 2D Gaussian over a pixel of unit area centered at :math:`(x, y)`, which is a product of error functions: .. 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 Because the model is integrated over the pixels, its values on a grid with a spacing of one pixel also sum to the total flux, for any subpixel position of the source: .. math:: \sum_{i=-\infty}^{\infty} \sum_{j=-\infty}^{\infty} f(x + i, y + j) = 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. This bound applies only during fitting. Directly setting a nonpositive width produces unphysical model values. 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_TINY, 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 before the integration over the pixels. """ 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 deviation (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 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 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 pixel-integrated 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))))) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, fwhm): """ Calculate the partial derivatives of the pixel-integrated 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. """ derivs = _circular_gaussian_prf_derivs( x, y, flux, x_0, y_0, fwhm * _GAUSSIAN_FWHM_TO_SIGMA) # Chain rule for change of variables from sigma to fwhm derivs[3] = derivs[3] * _GAUSSIAN_FWHM_TO_SIGMA return derivs @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): # The radial distance requires x and y to have the same unit. 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]], '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 a 2D Gaussian at a *subpixel* 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. sigma : float or `~astropy.units.Quantity`, optional The standard deviation 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, CircularGaussianPSF, GaussianPRF, CircularGaussianPRF, MoffatPSF, AiryDiskPSF, MoffatPRF, AiryDiskPRF Notes ----- The model is the integral of a circular 2D Gaussian over a pixel of unit area centered at :math:`(x, y)`, which is a product of error functions: .. 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 Because the model is integrated over the pixels, its values on a grid with a spacing of one pixel also sum to the total flux, for any subpixel position of the source: .. math:: \sum_{i=-\infty}^{\infty} \sum_{j=-\infty}^{\infty} f(x + i, y + j) = 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. This bound applies only during fitting. Directly setting a nonpositive width produces unphysical model values. 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_TINY, 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 before the integration over the pixels. """ 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 deviation (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 CircularGaussianSigmaPRF >>> model = CircularGaussianSigmaPRF(x_0=0, y_0=0, sigma=2) >>> model.bounding_box ModelBoundingBox( intervals={ x: Interval(lower=-11.0, upper=11.0) y: Interval(lower=-11.0, upper=11.0) } model=CircularGaussianSigmaPRF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box ModelBoundingBox( intervals={ x: Interval(lower=-14.0, upper=14.0) y: Interval(lower=-14.0, upper=14.0) } model=CircularGaussianSigmaPRF(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 pixel-integrated 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. sigma : float The standard deviation 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 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))))) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, sigma): """ Calculate the partial derivatives of the pixel-integrated 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. sigma : float The standard deviation of the Gaussian. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ return _circular_gaussian_prf_derivs(x, y, flux, x_0, y_0, 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): # The radial distance requires x and y to have the same unit. 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. alpha : float or `~astropy.units.Quantity`, optional The characteristic radius of the Moffat profile. beta : float, optional The power-law index of the Moffat profile. At large radial distances the profile wings fall off as ``r**(-2 * beta)``. 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 -------- MoffatPRF, GaussianPSF, CircularGaussianPSF, GaussianPRF, CircularGaussianPRF, CircularGaussianSigmaPRF, AiryDiskPSF 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. This model is evaluated at the input coordinates and is not integrated over the detector pixels. Its values on a grid of detector pixels are the values of the PSF at the pixel centers, not the fluxes in the pixels. For a PSF that is undersampled by the detector pixels, the model is sharper than a source in the data and the sum of its values over the pixels depends on the subpixel position of the source. This model should therefore not be used to fit the pixel values of an image, e.g., for PSF photometry. If its shape parameters are fixed to those of the PSF before the integration over the pixels, the fitted flux is biased by a few percent for a FWHM of 2 to 3 pixels, and by much more, with an error that depends on the subpixel position of the source, for a FWHM of less than about 1.5 pixels. Use `MoffatPRF` instead, which is integrated over the pixels. 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_TINY, None), fixed=True, description='Characteristic radius of the Moffat profile') beta = Parameter( default=2, bounds=(np.nextafter(1.0, np.inf), 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 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 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 The characteristic radius of the Moffat profile. beta : float The power-law index of the Moffat profile. At large radial distances the profile wings fall off as ``r**(-2 * beta)``. 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 alpha_norm = alpha if isinstance(alpha, u.Quantity): alpha_norm = alpha.value amp = flux * (beta - 1) / (np.pi * alpha_norm ** 2) r2 = (x - x_0) ** 2 + (y - y_0) ** 2 return amp * (1 + (r2 / alpha**2)) ** (-beta) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, alpha, beta): """ Calculate the partial derivatives of the 2D Moffat 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. alpha : float The characteristic radius of the Moffat profile. beta : float The power-law index of the Moffat profile. At large radial distances the profile wings fall off as ``r**(-2 * beta)``. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ dx = x - x_0 dy = y - y_0 r2_scaled = (dx**2 + dy**2) / alpha**2 base = 1.0 + r2_scaled # The profile for unit flux without the (beta - 1) factor of # the normalization, which is kept apart so that the beta # derivative is finite at beta = 1 profile = base ** (-beta) / (np.pi * alpha**2) model = flux * (beta - 1.0) * profile d_flux = (beta - 1.0) * profile position_factor = 2.0 * beta * model / (alpha**2 * base) d_x_0 = position_factor * dx d_y_0 = position_factor * dy d_alpha = 2.0 * model / alpha * (beta * r2_scaled / base - 1.0) d_beta = flux * profile * (1.0 - (beta - 1.0) * np.log(base)) return [d_flux, d_x_0, d_y_0, d_alpha, d_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 MoffatPRF(_PixelQuadratureMixin, MoffatPSF): r""" A 2D Moffat PSF model integrated over pixels. This model is evaluated by integrating the 2D Moffat function over the area of a pixel centered at each input position. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The response is assumed to be uniform across a pixel. The Moffat profile is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. alpha : float or `~astropy.units.Quantity`, optional The characteristic radius of the Moffat profile. beta : float, optional The power-law index of the Moffat profile. At large radial distances the profile wings fall off as ``r**(-2 * beta)``. Larger values provide less flux in the profile wings. ``beta`` must be greater than 1. bbox_factor : float, optional The multiple of the FWHM used to define the bounding box limits. n_nodes : int, optional The number of Gauss-Legendre quadrature nodes along each axis of a pixel that are used to integrate the profile over the pixel. Must be a positive integer. The default is 9. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- MoffatPSF, AiryDiskPRF, GaussianPRF, CircularGaussianPRF, CircularGaussianSigmaPRF Notes ----- The model is the integral of the Moffat profile over a pixel of unit area that is centered at :math:`(x, y)`: .. math:: f(x, y) = \int_{y - 0.5}^{y + 0.5} \int_{x - 0.5}^{x + 0.5} g(u, v) \,du \,dv where the Moffat profile is: .. math:: g(u, v) = F \frac{\beta - 1}{\pi \alpha^2} \left(1 + \frac{\left(u - x_{0}\right)^{2} + \left(v - y_{0}\right)^{2}}{\alpha^{2}}\right)^{-\beta} :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 integral over each pixel is computed with Gauss-Legendre quadrature using ``n_nodes`` nodes along each axis of the pixel. For ``beta`` of at least 1.5, the default of 9 nodes gives values that are accurate to better than :math:`10^{-4}` of the peak for a FWHM of 0.5 pixels and to better than :math:`10^{-7}` of the peak for a FWHM of at least 1 pixel. The errors are about twice as large for ``beta`` close to 1. The partial derivatives used for fitting are computed with the same quadrature. The profile is evaluated at ``n_nodes**2`` points in each pixel, so this model is much slower than `MoffatPSF`. With the default of 9 nodes, it is roughly 5 to 50 times slower on the small cutouts used for fitting, depending on the cutout size, and about 80 times slower on a large image. A smaller ``n_nodes`` is faster and less accurate. Because the model is integrated over the pixels, its values on a grid with a spacing of one pixel sum to the total flux, for any subpixel position of the source: .. math:: \sum_{i=-\infty}^{\infty} \sum_{j=-\infty}^{\infty} f(x + i, y + j) = F The FWHM of the Moffat profile before the integration over the pixels is given by: .. math:: \rm{FWHM} = 2 \alpha \sqrt{2^{1 / \beta} - 1} 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 MoffatPRF >>> model = MoffatPRF() >>> 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. Examples -------- The values of an undersampled model on a grid of pixels sum to the flux for any subpixel position of the source: >>> import numpy as np >>> from photutils.psf import MoffatPRF >>> model = MoffatPRF(flux=71.4, x_0=50.3, y_0=49.8, alpha=0.9, ... beta=3.5) >>> yy, xx = np.mgrid[0:101, 0:101] >>> print(f'{model(xx, yy).sum():.2f}') 71.40 .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import MoffatPRF model = MoffatPRF(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') """ def __init__(self, *, flux=MoffatPSF.flux.default, x_0=MoffatPSF.x_0.default, y_0=MoffatPSF.y_0.default, alpha=MoffatPSF.alpha.default, beta=MoffatPSF.beta.default, bbox_factor=10.0, n_nodes=9, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, alpha=alpha, beta=beta, bbox_factor=bbox_factor, **kwargs) self.n_nodes = n_nodes def evaluate(self, x, y, flux, x_0, y_0, alpha, beta): """ Calculate the value of the pixel-integrated 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 The characteristic radius of the Moffat profile. beta : float The power-law index of the Moffat profile. At large radial distances the profile wings fall off as ``r**(-2 * beta)``. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ return _integrate_over_pixels( lambda xsub, ysub: MoffatPSF.evaluate(self, xsub, ysub, flux, x_0, y_0, alpha, beta), x, y, (flux, x_0, y_0, alpha, beta), self.n_nodes) def fit_deriv(self, x, y, flux, x_0, y_0, alpha, beta): """ Calculate the partial derivatives of the pixel-integrated 2D Moffat 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. alpha : float The characteristic radius of the Moffat profile. beta : float The power-law index of the Moffat profile. At large radial distances the profile wings fall off as ``r**(-2 * beta)``. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ return _integrate_over_pixels( lambda xsub, ysub: MoffatPSF.fit_deriv(xsub, ysub, flux, x_0, y_0, alpha, beta), x, y, (flux, x_0, y_0, alpha, beta), self.n_nodes) 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. radius : float or `~astropy.units.Quantity`, 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 -------- AiryDiskPRF, GaussianPSF, CircularGaussianPSF, GaussianPRF, CircularGaussianPRF, CircularGaussianSigmaPRF, MoffatPSF Notes ----- The Airy disk profile is defined as: .. math:: f(r) = \frac{\pi F}{4 (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 the 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. This model is evaluated at the input coordinates and is not integrated over the detector pixels. Its values on a grid of detector pixels are the values of the PSF at the pixel centers, not the fluxes in the pixels. For a PSF that is undersampled by the detector pixels, the model is sharper than a source in the data and the sum of its values over the pixels depends on the subpixel position of the source. This model should therefore not be used to fit the pixel values of an image, e.g., for PSF photometry. If its shape parameters are fixed to those of the PSF before the integration over the pixels, the fitted flux is biased by a few percent for a FWHM of 2 to 3 pixels, and by much more, with an error that depends on the subpixel position of the source, for a FWHM of less than about 1.5 pixels. Use `AiryDiskPRF` instead, which is integrated over the pixels. 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_TINY, None), fixed=True, description='Radius of the Airy disk at the first zero') # The radius of the first zero of the Airy disk profile, in units of # the profile scale radius, i.e., the solution of J1(pi * Rz) = 0. _rz = jn_zeros(1, 1)[0] / np.pi # The half width at half maximum of the Airy disk profile, i.e., the # solution of (2 * J1(x) / x)**2 = 1/2. _hwhm = 1.616339948310703 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 * self._hwhm * 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 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 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 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): # Convert to dimensionless_unscaled to avoid unit conversion # issues in scipy functions, since they expect dimensionless # inputs. r = r.to_value(u.dimensionless_unscaled) r = np.asarray(r, dtype=float) # Since r can be zero, we have to take care to treat that case # separately so as not to raise a numpy warning. The limit of # (2 * J1(x) / x)**2 as x approaches zero is 1. rt = np.pi * np.atleast_1d(r) z = np.ones(rt.shape) nonzero = rt > 0 z[nonzero] = (2.0 * j1(rt[nonzero]) / rt[nonzero]) ** 2 # The model tends to zero at infinite radial distance, but # j1(inf) is NaN, so set the limit explicitly z[np.isinf(rt)] = 0.0 z[np.isnan(rt)] = np.nan z = z.reshape(r.shape) normalization = (4.0 / np.pi) * (radius / self._rz) ** 2 if isinstance(normalization, u.Quantity): normalization = normalization.value # Not an in-place multiplication because a flux array can have # more dimensions than the input coordinates return z * (flux / normalization) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, radius): """ Calculate the partial derivatives of the 2D Airy disk 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. radius : float The radius of the Airy disk at the first zero. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ scale = radius / AiryDiskPSF._rz dx, dy = np.broadcast_arrays(x - x_0, y - y_0) rt = np.pi * np.hypot(dx, dy) / scale rt = np.atleast_1d(np.asarray(rt, dtype=float)) # The profile is (2 J1(t) / t)**2 and its derivative with # respect to t is -8 J1(t) J2(t) / t**2. Both are computed # from J1(t) / t and J2(t) / t**2, whose limits as t approaches # zero are 1/2 and 1/8. j1_ratio = np.full(rt.shape, 0.5) nonzero = rt > 0 j1_ratio[nonzero] = j1(rt[nonzero]) / rt[nonzero] # J2(t) comes from the recurrence J2(t) = 2 J1(t) / t - J0(t), # which is much faster than evaluating it directly. The # recurrence loses precision as t approaches zero, where the # power series of J2(t) / t**2 is used instead. j2_ratio = np.empty(rt.shape) small = rt < 0.3 rt2 = rt[small] ** 2 j2_ratio[small] = 0.125 * (1.0 - rt2 / 12.0 * ( 1.0 - rt2 / 32.0 * (1.0 - rt2 / 60.0 * (1.0 - rt2 / 96.0)))) large = ~small j2_ratio[large] = ((2.0 * j1_ratio[large] - j0(rt[large])) / rt[large] ** 2) shape = np.shape(dx) j1_ratio = j1_ratio.reshape(shape) j2_ratio = j2_ratio.reshape(shape) rt = rt.reshape(shape) amplitude = np.pi / (4.0 * scale**2) profile = 4.0 * j1_ratio**2 # The derivative of the profile divided by t, which is finite # at t = 0 d_profile_over_t = -8.0 * j1_ratio * j2_ratio d_flux = amplitude * profile position_factor = (-flux * amplitude * d_profile_over_t * (np.pi / scale) ** 2) d_x_0 = position_factor * dx d_y_0 = position_factor * dy d_scale = (-flux * amplitude / scale * (2.0 * profile + d_profile_over_t * rt**2)) d_radius = d_scale / AiryDiskPSF._rz return [d_flux, d_x_0, d_y_0, d_radius] @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]]} class AiryDiskPRF(_PixelQuadratureMixin, AiryDiskPSF): r""" A 2D Airy disk PSF model integrated over pixels. This model is evaluated by integrating the 2D Airy disk function over the area of a pixel centered at each input position. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The response is assumed to be uniform across a pixel. 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 or `~astropy.units.Quantity`, optional Total integrated flux over the entire PSF. x_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the x-axis. y_0 : float or `~astropy.units.Quantity`, optional Position of the peak along the y-axis. radius : float or `~astropy.units.Quantity`, 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. n_nodes : int, optional The number of Gauss-Legendre quadrature nodes along each axis of a pixel that are used to integrate the profile over the pixel. Must be a positive integer. The default is 9. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- AiryDiskPSF, MoffatPRF, GaussianPRF, CircularGaussianPRF, CircularGaussianSigmaPRF Notes ----- The model is the integral of the Airy disk profile over a pixel of unit area that is centered at :math:`(x, y)`: .. math:: f(x, y) = \int_{y - 0.5}^{y + 0.5} \int_{x - 0.5}^{x + 0.5} g(u, v) \,du \,dv where the Airy disk profile is: .. math:: g(u, v) = \frac{\pi F}{4 (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 .. math:: r = \sqrt{(u - x_0)^2 + (v - y_0)^2} :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, :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`. The integral over each pixel is computed with Gauss-Legendre quadrature using ``n_nodes`` nodes along each axis of the pixel. The default of 9 nodes gives values that are accurate to better than :math:`10^{-7}` of the peak for a FWHM of at least 0.5 pixels. The partial derivatives used for fitting are computed with the same quadrature. The profile is evaluated at ``n_nodes**2`` points in each pixel, so this model is much slower than `AiryDiskPSF`. With the default of 9 nodes, it is roughly 5 to 50 times slower on the small cutouts used for fitting, depending on the cutout size, and about 80 times slower on a large image. A smaller ``n_nodes`` is faster and less accurate. Because the model is integrated over the pixels, its values on a grid with a spacing of one pixel sum to the total flux, for any subpixel position of the source: .. math:: \sum_{i=-\infty}^{\infty} \sum_{j=-\infty}^{\infty} f(x + i, y + j) = F The flux in the wings of an Airy disk decreases slowly with the distance from the peak, so the sum over a finite grid of pixels is smaller than the total flux. The FWHM of the Airy disk profile before the integration over the pixels is given by: .. math:: \rm{FWHM} = 1.028993969962188 \, \frac{R}{R_z} = 0.8436659602162364 \, R 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 AiryDiskPRF >>> model = AiryDiskPRF() >>> model.radius.fixed = False By default, the ``radius`` parameter is bounded to be strictly positive. Examples -------- The values of an undersampled model on a grid of pixels sum to the same fraction of the flux for any subpixel position of the source: >>> import numpy as np >>> from photutils.psf import AiryDiskPRF >>> yy, xx = np.mgrid[0:101, 0:101] >>> for x_0, y_0 in ((50.0, 50.0), (50.5, 50.5), (50.3, 49.8)): ... model = AiryDiskPRF(flux=100.0, x_0=x_0, y_0=y_0, radius=1.0) ... print(f'{model(xx, yy).sum():.2f}') 99.70 99.70 99.70 .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.psf import AiryDiskPRF model = AiryDiskPRF(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') """ def __init__(self, *, flux=AiryDiskPSF.flux.default, x_0=AiryDiskPSF.x_0.default, y_0=AiryDiskPSF.y_0.default, radius=AiryDiskPSF.radius.default, bbox_factor=10.0, n_nodes=9, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, radius=radius, bbox_factor=bbox_factor, **kwargs) self.n_nodes = n_nodes def evaluate(self, x, y, flux, x_0, y_0, radius): """ Calculate the value of the pixel-integrated 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 The radius of the Airy disk at the first zero. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ return _integrate_over_pixels( lambda xsub, ysub: AiryDiskPSF.evaluate(self, xsub, ysub, flux, x_0, y_0, radius), x, y, (flux, x_0, y_0, radius), self.n_nodes) def fit_deriv(self, x, y, flux, x_0, y_0, radius): """ Calculate the partial derivatives of the pixel-integrated 2D Airy disk 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. radius : float The radius of the Airy disk at the first zero. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ return _integrate_over_pixels( lambda xsub, ysub: AiryDiskPSF.fit_deriv(xsub, ysub, flux, x_0, y_0, radius), x, y, (flux, x_0, y_0, radius), self.n_nodes) astropy-photutils-24be529/photutils/psf/gridded_models.py000066400000000000000000001511471526452531300237630ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Gridded PSF models. """ import bisect import copy import itertools import threading from functools import cached_property import numpy as np from astropy.io import registry from astropy.modeling import Fittable2DModel, Parameter from astropy.nddata import NDData from scipy.interpolate import RectBivariateSpline from photutils.psf._bispline import bispline_sum, bispline_sum_deriv from photutils.psf._bispline_inputs import ONE_PLANE, UNIT_WEIGHT, ZERO_WEIGHT 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.psf.utils import _copy_model_sharing_data, _out_of_grid_mask from photutils.utils._parameters import as_pair __all__ = ['GriddedPSFModel', 'STDPSFGrid'] __doctest_skip__ = ['STDPSFGrid'] # The instance attributes that hold the spline cache of a GriddedPSFModel _SPLINE_CACHE_NAMES = ('_spline_knots', '_spline_coeffs', '_spline_filled', '_spline_lock') class GriddedPSFModel(Fittable2DModel): """ A model representing a grid of 2D ePSF models. The ePSF models are defined at fiducial detector locations specified by their ``(x, y)`` detector coordinates. The fiducial locations must form a rectangular grid. This model has three parameters: an image intensity scaling factor (``flux``), which scales the input image, and two positional parameters (``x_0`` and ``y_0``), which specify the location of the feature in the coordinate grid where the model is evaluated. When evaluating this model, the input ``x`` and ``y`` arrays must have no more than two dimensions. Parameters ---------- nddata : `~astropy.nddata.NDData` A `~astropy.nddata.NDData` object containing the reference ePSF grid. Its ``data`` attribute must be a 3D `~numpy.ndarray` with shape ``(N_psf, ePSF_ny, ePSF_nx)``, where each plane is a 2D ePSF image. The x and y dimensions of each ePSF image must both be at least 4 pixels. ``N_psf`` must not be 2 or 3. All values in ``data`` must be finite. The ePSF peak is assumed to be centered in each input image. See the Notes section for details on the required normalization of the input ePSF images. If ``N_psf`` is 1, the single ePSF image is used at all detector positions. This is equivalent to using `~photutils.psf.ImagePSF` with the same ePSF image. The ``meta`` attribute must be a dictionary containing: * ``'grid_xypos'``: A sequence of the fiducial ``(x, y)`` detector coordinates for each reference ePSF. The order must match the first axis of ``data``. That is, ``grid_xypos[i]`` gives the detector coordinates of ``nddata.data[i]``. The coordinates must form a rectangular grid. * ``'oversampling'``: The integer oversampling factor(s) of the input ePSF images. If a scalar is provided, it is applied to both axes. If two values are provided, they must be in ``(y, x)`` order. The ``meta`` dictionary may also contain additional metadata, such as the telescope, instrument, detector, or filter. flux : float, optional The flux scaling factor. This corresponds to the total source flux, assuming the input ePSF images are properly normalized. x_0, y_0 : float, optional The ``(x, y)`` coordinates of the ePSF peak in the output coordinate grid where the model is evaluated. fill_value : float or `None`, optional The value used for points outside the input pixel grid. The default is 0.0. If `None`, a point outside the input pixel grid takes the value of the spline at the nearest point on the edge of the grid. Methods ------- read(*args, **kwargs) Class method to create a `GriddedPSFModel` instance from a STDPSF FITS file or a WebbPSF (STPSF) FITS file. The file format is identified automatically, or it can be set with the ``format`` keyword (``'stdpsf'`` or ``'webbpsf'``). This method uses :func:`~photutils.psf.stdpsf_reader` or :func:`~photutils.psf.webbpsf_reader` with the provided parameters. See Also -------- ImagePSF : A model for a single ePSF image. make_epsf_from_psf : Make an ePSF image from a sampled PSF. Notes ----- The input images must be effective PSFs (ePSFs). Each value of an ePSF is the fraction of the source flux that falls in a whole detector pixel centered at that position relative to the source, even when the image is oversampled. The model interpolates the input images and does not integrate them over the detector pixels. Evaluating the model at a position ``(x, y)`` therefore gives the flux in a detector pixel centered at ``(x, y)``, which can be any fractional pixel position. Because each value is the flux in a whole detector pixel, the model values sum to ``flux`` only when the model is evaluated on a grid with a spacing of one detector pixel. That holds for any values of ``x_0`` and ``y_0``. On a finer grid the pixels overlap, and the sum is larger than ``flux`` by the ratio of the pixel area to the area of a grid cell. Oversampled images that are not ePSFs, such as PSFs sampled at the points of a fine grid or binned into subpixels, are not converted to ePSFs by this model. The model is then sharper than a source in the data. For an undersampled PSF, the sum of the model values over the detector pixels can also change with the subpixel position of the source. Such images should first be integrated over the area of a detector pixel centered at each of their grid points, which is what `make_epsf_from_psf` does. The fitted ``flux`` parameter represents the total source flux, provided the input ePSF images are properly normalized. The fitted flux is a multiplicative scale factor applied to the input ePSF after accounting for any oversampling. For a fully sampled ePSF (i.e., no oversampling), the sum of the ePSF values over an infinite grid is 1.0. Because ePSFs are represented by finite images in practice, the sum of the array values may be less than 1.0. For oversampled ePSFs, the normalization should instead be such that the sum of the array values over an infinite grid equals the product of the oversampling factors (e.g., ``oversampling**2`` when the oversampling is the same along both axes). Again, a finite image will generally have a smaller sum because it does not contain the full PSF wings. If the input ePSF image covers only a finite region of the PSF, correction factors based on the encircled or ensquared energy can be used to estimate the missing flux and obtain the proper normalization. With the default ``fill_value`` of 0, the model is zero outside of the extent of the input ePSF images. The model values on a grid of detector pixels therefore sum to less than ``flux`` by the fraction of the source flux that is outside of the images. That sum also changes with the subpixel position of the source, because the number of detector pixels inside the images changes (see :ref:`psf-image-models` for example values). The input images should therefore be large enough that the ePSF is small at their edges. Internally, the ePSF grid is reordered so that the reference ePSFs are sorted first by their y detector coordinate and then by their x detector coordinate. Each grid plane is interpolated with a bicubic spline (`~scipy.interpolate.RectBivariateSpline` with ``kx=ky=3`` and ``s=0``). The spline coefficients of each evaluated grid plane are cached on the model, in a float64 array with the same number of elements as the grid data, and shared across copies made with the `copy` method. When every grid plane has been evaluated, the cache roughly doubles the model's memory footprint for float64 grid data and triples it for float32 grid data. The cache is not included when the model is pickled, and a deep copy gets its own copy of it. The splines of the four bounding grid planes are evaluated together by a compiled kernel, which also computes the analytic partial derivatives used by `fit_deriv`. Examples -------- Oversampled PSFs whose values are samples of the PSF, such as the output of an optical model, must be converted to ePSFs before they are used as the input images. Here, four narrow Gaussian PSFs are sampled on a grid that is oversampled by a factor of 4 and then integrated over the detector pixels with `make_epsf_from_psf`: >>> import numpy as np >>> from astropy.nddata import NDData >>> from photutils.psf import GriddedPSFModel, make_epsf_from_psf >>> oversampling = 4 >>> yy, xx = np.mgrid[-30:31, -30:31] / oversampling >>> sigmas = (0.5, 0.55, 0.6, 0.65) # detector pixels >>> psfs = np.array([np.exp(-(xx**2 + yy**2) / (2 * sigma**2)) ... for sigma in sigmas]) >>> psfs *= oversampling**2 / psfs.sum(axis=(1, 2), keepdims=True) >>> epsfs = make_epsf_from_psf(psfs, oversampling=oversampling) >>> meta = {'grid_xypos': [(0, 0), (100, 0), (0, 100), (100, 100)], ... 'oversampling': oversampling} >>> model = GriddedPSFModel(NDData(epsfs, meta=meta), x_0=40.3, ... y_0=29.6) The model values on a grid of detector pixels sum to the model flux for any subpixel position of the source: >>> yy, xx = np.mgrid[23:38, 33:48] >>> print(f'{model(xx, yy).sum():.3f}') 1.000 """ 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 # Drop a stale user-supplied key. The grid shape is derived # from grid_xypos self._meta.pop('grid_shape', None) # The setter also stores the normalized (y, x) pair in meta self.oversampling = nddata.meta['oversampling'] self.fill_value = fill_value self._xgrid = np.unique(self.grid_xypos[:, 0]) # sorted self._ygrid = np.unique(self.grid_xypos[:, 1]) # sorted self._grid_shape = (len(self._ygrid), len(self._xgrid)) # Store the sorted grid positions so that meta always matches # the grid_xypos attribute, regardless of the input form self.meta['grid_xypos'] = self.grid_xypos self._init_spline_cache() super().__init__(flux, x_0, y_0) def _init_spline_cache(self): """ Create the empty cache of the grid-plane splines. The cache holds the knot vectors, which are the same for every grid plane, and the spline coefficients of each grid plane. It is filled on first use by `_fill_spline_coefficients`. The containers are created here rather than lazily so that the model copies made with `copy` share them. The knots and the filled flags are kept in lists, not arrays. A list item is replaced atomically, also on free-threaded builds, so a thread that reads a filled flag or the knots without the lock sees values that are completely written. """ n_grid, ny, nx = self._data.shape # A one-item holder for the (tx, ty) tuple of knot vectors self._spline_knots = [None] # A bicubic interpolating spline has as many coefficients as # data points self._spline_coeffs = np.empty((n_grid, ny * nx), dtype=float) self._spline_filled = [False] * n_grid self._spline_lock = threading.Lock() def __getstate__(self): """ Return the model state for pickling, without the spline cache. The cache is as large as the grid data and can be rebuilt from it, so it is left out of pickles. """ state = self.__dict__.copy() for name in _SPLINE_CACHE_NAMES: del state[name] return state def __setstate__(self, state): self.__dict__.update(state) # Discard the spline objects that are in the pickles of models # made by earlier versions, which cached them on the model. for name in ('_interpolator', '_deriv_interpolators'): self.__dict__.pop(name, None) self._init_spline_cache() def __copy__(self): """ Return a shallow copy that shares the spline cache. A shallow copy is made from the pickle state by default, which leaves the spline cache out, so it is added to the copy. """ new_model = object.__new__(self.__class__) new_model.__setstate__(self.__getstate__()) for name in _SPLINE_CACHE_NAMES: new_model.__dict__[name] = self.__dict__[name] return new_model def __deepcopy__(self, memo): """ Return a deep copy with its own copy of the spline cache. The coefficients of the planes that are already cached are copied, so the deep copy does not rebuild their splines. A compound model that contains this model is deep copied for every source in PSF photometry. The caches of the two models are independent afterward. The copy is made from the pickle state, so that the ``__getstate__`` and ``__setstate__`` methods of a subclass apply to deep copies as well. """ new_model = object.__new__(self.__class__) memo[id(self)] = new_model new_model.__setstate__(copy.deepcopy(self.__getstate__(), memo)) # A plane that is flagged as filled is completely written and # is never written again, so it can be copied while other # threads fill other planes. filled = list(self._spline_filled) for gidx, is_filled in enumerate(filled): if is_filled: new_model._spline_coeffs[gidx] = self._spline_coeffs[gidx] new_model._spline_filled[:] = filled # The knot vectors are never modified, so they are shared new_model._spline_knots[0] = self._spline_knots[0] return new_model @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, contains NaNs or infs, has 2 or 3 ePSFs, has an x- or y-axis shorter than 4 pixels, or has no ``'oversampling'`` key in its ``meta``. """ 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) # The minimum number of data points required is 4 along each # axis. This is because RectBivariateSpline requires at least 4 # points along each axis for cubic spline interpolation (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. """ # Fewer than 4 positions cannot form a 2D rectangular grid if len(grid_xypos) < 4: 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 ``'grid_xypos'`` is missing from ``meta``, its length does not match the number of ePSFs, it contains duplicate positions, or it has more than one position and does not form a rectangular grid. """ try: grid_xypos = np.asarray(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({tuple(pos) for pos in grid_xypos}) != len(grid_xypos): msg = 'grid_xypos must not contain duplicate positions' raise ValueError(msg) if len(grid_xypos) != 1 and not self._is_rectangular_grid(grid_xypos): msg = ('grid_xypos must form a rectangular grid, i.e., there ' 'must be at least two unique x and y positions and ' 'every combination of them must be present') 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.asarray(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 = [] oversampling = tuple(int(value) for value in self.oversampling) 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.grid_shape), ('Grid positions', self.grid_xypos), ('PSF shape (oversampled pixels)', self.data.shape[1:]), ('Oversampling', oversampling), ('Fill Value', self.fill_value)]) with np.printoptions(threshold=25, edgeitems=5): 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, as an ``(N_psf, 2)`` array. The positions are sorted first by y and then by x, which can differ from the order of the input ``grid_xypos``. ``grid_xypos[i]`` is the (x, y) position of the reference ePSF in ``data[i]``. """ return self._grid_xypos @property def grid_shape(self): """ The ``(ny, nx)`` shape of the ePSF grid. """ return self._grid_shape def copy(self): """ Return a copy of this model where only the model parameters are copied. All other model attributes are references to the original model, except the ``meta`` dictionary, which is copied shallowly. 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. """ new_model = _copy_model_sharing_data(self) # Give the copy its own meta dictionary so that setting the # oversampling on the copy does not mutate the original meta new_model._meta = dict(self._meta) return new_model def deepcopy(self): """ Return a deep copy of this model. The deep copy gets its own copy of the cached spline coefficients, so it does not rebuild the splines that this model already has. Returns ------- result : `GriddedPSFModel` A deep copy of this model. """ return copy.deepcopy(self) @property def oversampling(self): """ The integer oversampling factors of the input ePSF images, as a 1D `~numpy.ndarray` in ``(y, x)`` order. The value can be set with a positive integer, which is used for both axes, or with two positive integers 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)) # Keep meta in sync with the normalized (y, x) pair self.meta['oversampling'] = tuple(int(val) for val in self._oversampling) def _calc_bounding_box(self): """ Return 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 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() @cached_property def origin(self): """ The (x, y) pixel coordinates, as a 1D `~numpy.ndarray`, of the origin of the coordinate system within the model image. """ # data.shape is (N_psf, ePSF_ny, ePSF_nx). The leading axis is # excluded so that the result is the (x, y) image center xyorigin = (np.array(self.data.shape[1:]) - 1) / 2 return xyorigin[::-1] @cached_property 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 the ePSF image at the given grid index. 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. """ # RectBivariateSpline expects the data to be in (x, y) axis order data = self.data[grid_idx] return RectBivariateSpline(*self._interp_xyidx, data.T, kx=3, ky=3, s=0) def _fill_spline_coefficients(self, grid_idx, *weights): """ Ensure that the spline knots and the spline coefficients of the given grid planes are in the spline cache. Only the knots and coefficients are kept. The spline object that computes them holds its own copy of the coefficients and is discarded. The spline of a plane is not built if all of its weights are zero, because the kernels skip such a plane. Several threads may build the spline of the same plane at the same time, but only the first one stores it. A plane that is flagged as filled is never written again, so the threads that evaluate it need no lock. Parameters ---------- grid_idx : `~numpy.ndarray` The indices of the grid planes. *weights : `~numpy.ndarray` The weights of the planes that the kernel is called with (the bilinear weights and, for the derivative kernel, their derivatives). """ for i, gidx in enumerate(grid_idx): gidx = int(gidx) if self._spline_filled[gidx]: continue if not any(weight[i] != 0.0 for weight in weights): continue interp = self._calc_interpolator(gidx) tx, ty = interp.get_knots() coeffs = interp.get_coeffs() with self._spline_lock: if self._spline_filled[gidx]: continue if self._spline_knots[0] is None: # Every grid plane has the same shape, so the knots # are the same for all of them self._spline_knots[0] = (np.ascontiguousarray(tx), np.ascontiguousarray(ty)) self._spline_coeffs[gidx] = coeffs self._spline_filled[gidx] = True def _bounding_weights(self, x_0, y_0, *, derivs=False): """ Return the grid indices, bilinear weights, and weight derivatives of the grid planes that bound a model position. For a single-plane grid, the one plane has unit weight and zero weight derivatives. Parameters ---------- x_0, y_0 : float The (x, y) position of the model. derivs : bool, optional Whether to calculate the weight derivatives. Returns ------- grid_idx, weights, dw_dx, dw_dy : `~numpy.ndarray` or `None` The plane indices and their weights and weight derivatives with respect to the model x and y positions. The weight derivatives are `None` if ``derivs`` is `False`. """ if self.data.shape[0] == 1: dw_dx = ZERO_WEIGHT if derivs else None return ONE_PLANE, UNIT_WEIGHT, dw_dx, dw_dx grid_idx, grid_xy = self._find_bounding_points(x_0, y_0) weights = self._calc_bilinear_weights(x_0, y_0, grid_xy) if not derivs: return grid_idx, weights, None, None dw_dx, dw_dy = self._calc_bilinear_weight_derivs(x_0, y_0, grid_xy) return grid_idx, weights, dw_dx, dw_dy @cached_property def _xgrid_list(self): """ A plain Python list of the sorted unique x grid positions. This is used for fast scalar lookups with `bisect.bisect_right`, which is faster than `numpy.searchsorted` for scalar inputs. """ return [float(v) for v in self._xgrid] @cached_property def _ygrid_list(self): """ A plain Python list of the sorted unique y grid positions. This is used for fast scalar lookups with `bisect.bisect_right`, which is faster than `numpy.searchsorted` for scalar inputs. """ return [float(v) for v in self._ygrid] @cached_property def _bounding_lookup(self): """ A precomputed lookup table mapping grid-cell indices to the source indices of the four bounding ePSF models. The array has shape ``(nx - 1, ny - 1, 4)`` and dtype intp, where ``nx`` and ``ny`` are the number of unique x and y grid positions, respectively. For a grid cell ``(xidx, yidx)``, the last axis contains the source indices of the four bounding ePSFs in the order (lower-left, lower-right, upper-left, upper-right). Precomputing this table avoids the repeated `numpy.where` searches over ``grid_xypos`` that would otherwise be performed for every model evaluation. """ nx = len(self._xgrid) ny = len(self._ygrid) # Map each reference (x, y) grid position to its source index. # The grid is rectangular, so every corner is present. pos_to_idx = {(float(x), float(y)): idx for idx, (x, y) in enumerate(self.grid_xypos)} # The spline kernels take the indices as intp out = np.empty((nx - 1, ny - 1, 4), dtype=np.intp) for ix in range(nx - 1): x0 = float(self._xgrid[ix]) x1 = float(self._xgrid[ix + 1]) for iy in range(ny - 1): y0 = float(self._ygrid[iy]) y1 = float(self._ygrid[iy + 1]) # Corners in (lower-left, lower-right, upper-left, # upper-right) order out[ix, iy] = (pos_to_idx[(x0, y0)], pos_to_idx[(x1, y0)], pos_to_idx[(x0, y1)], pos_to_idx[(x1, y1)]) return out 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 cell is selected. This method is a scalar-only fast path. ``x`` and ``y`` must be scalar values (the model ``x_0`` and ``y_0`` positions), which is always the case when called from ``_bounding_weights``. Parameters ---------- x, y : float The scalar (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 : tuple of 4 float The x and y coordinates of the four bounding points. The order is left, right, bottom, top. """ # Scalar fast path: use bisect on the precomputed float list and # the precomputed corner lookup table for efficient grid-cell # indexing. This avoids the numpy call overhead (searchsorted, # clip, and where) that dominates for scalar inputs. The indices # are clamped so that out-of-grid inputs select the nearest grid # cell. nx = len(self._xgrid) ny = len(self._ygrid) xidx = bisect.bisect_right(self._xgrid_list, float(x)) - 1 yidx = bisect.bisect_right(self._ygrid_list, float(y)) - 1 if xidx < 0: xidx = 0 elif xidx > nx - 2: xidx = nx - 2 if yidx < 0: yidx = 0 elif yidx > ny - 2: yidx = ny - 2 # The coordinates are taken from the float lists so that the # bilinear weights computed from them are float64 for grid # positions of any dtype, which the spline kernels require. They # are returned as Python floats because the arithmetic of the # weights is faster with them than with NumPy scalars. x0 = self._xgrid_list[xidx] x1 = self._xgrid_list[xidx + 1] y0 = self._ygrid_list[yidx] y1 = self._ygrid_list[yidx + 1] grid_idx = self._bounding_lookup[xidx, yidx] return grid_idx, (x0, x1, y0, y1) 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. This method is a scalar-only fast path. ``xi`` and ``yi`` must be scalar values (the model ``x_0`` and ``y_0`` positions). Parameters ---------- xi, yi : float The scalar (x_0, y_0) position of the model. grid_xy : sequence of 4 float 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 # Scalar fast path: clamp the coordinates to the grid bounds # using scalar conditionals instead of numpy.clip, which avoids # the numpy call overhead for scalar inputs. xi = float(xi) yi = float(yi) if xi < x0: xi = x0 elif xi > x1: xi = x1 if yi < y0: yi = y0 elif yi > y1: yi = y1 # Weights of the four bounding points for bilinear # interpolation. The order is lower-left, lower-right, # upper-left, upper-right. inv_norm = 1.0 / ((x1 - x0) * (y1 - y0)) ll = (x1 - xi) * (y1 - yi) * inv_norm lr = (xi - x0) * (y1 - yi) * inv_norm ul = (x1 - xi) * (yi - y0) * inv_norm ur = (xi - x0) * (yi - y0) * inv_norm return np.array((ll, lr, ul, ur)) def _calc_bilinear_weight_derivs(self, xi, yi, grid_xy): """ Calculate the partial derivatives of the bilinear interpolation weights with respect to the (xi, yi) coordinate. This method is a scalar-only fast path. ``xi`` and ``yi`` must be scalar values (the model ``x_0`` and ``y_0`` positions). `_calc_bilinear_weights` clamps the coordinates to the grid cell, so the weights are constant outside the cell and the corresponding derivatives are zero there. Parameters ---------- xi, yi : float The scalar (x_0, y_0) position of the model. grid_xy : sequence of 4 float The x and y coordinates of the four bounding points. The order is left, right, bottom, top. Returns ------- dw_dx, dw_dy : `~numpy.ndarray` The partial derivatives of the bilinear weights for the four bounding points with respect to ``xi`` and ``yi``. The order is lower-left, lower-right, upper-left, upper-right. """ x0, x1, y0, y1 = grid_xy xi = float(xi) yi = float(yi) inv_norm = 1.0 / ((x1 - x0) * (y1 - y0)) # Clamp the coordinates as in _calc_bilinear_weights. The # clamped values are used in the derivatives along the other # axis. x_inside = x0 <= xi <= x1 y_inside = y0 <= yi <= y1 xc = min(max(xi, x0), x1) yc = min(max(yi, y0), y1) if x_inside: dw_dx = np.array((-(y1 - yc), (y1 - yc), -(yc - y0), (yc - y0))) * inv_norm else: dw_dx = np.zeros(4) if y_inside: dw_dy = np.array((-(x1 - xc), -(xc - x0), (x1 - xc), (xc - x0))) * inv_norm else: dw_dy = np.zeros(4) return dw_dx, dw_dy 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 : `~numpy.ndarray` The (x, y) pixel coordinates in the oversampled ePSF image at which the interpolated ePSF is evaluated. The two arrays must have the same shape. Returns ------- result : `~numpy.ndarray` The interpolated ePSF model at the input (x_0, y_0) coordinate. """ grid_idx, weights, _, _ = self._bounding_weights(x_0, y_0) self._fill_spline_coefficients(grid_idx, weights) tx, ty = self._spline_knots[0] xi = np.ascontiguousarray(xi, dtype=float) yi = np.ascontiguousarray(yi, dtype=float) result = np.empty(xi.shape, dtype=float) bispline_sum(tx, ty, self._spline_coeffs, grid_idx, weights, xi.ravel(), yi.ravel(), result.ravel()) 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. """ # Promote scalar inputs to 1D arrays so that the interpolator # returns an array that supports masked assignment below, # regardless of the scipy version x = np.atleast_1d(x) y = np.atleast_1d(y) 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 xi.shape != yi.shape: xi, yi = np.broadcast_arrays(xi, yi) 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 invalid = _out_of_grid_mask(xi, yi, self.data.shape[1:]) evaluated_model[invalid] = self.fill_value return evaluated_model def fit_deriv(self, x, y, flux, x_0, y_0): """ Calculate the partial derivatives of the ePSF model with respect to the model parameters. Providing this analytic Jacobian allows the fitter to avoid the finite-difference approximation, which requires additional model evaluations. The position derivatives include both the shift of the ePSF with the model position and the change of the bilinearly interpolated ePSF itself with the model position across the reference grid. 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 ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to the ``flux``, ``x_0``, and ``y_0`` parameters. """ # Promote scalar inputs to 1D arrays so that the interpolator # returns an array that supports masked assignment below, # regardless of the scipy version x = np.atleast_1d(x) y = np.atleast_1d(y) if x.ndim > 2: msg = 'x and y must be 1D or 2D' raise ValueError(msg) # The fitting machinery may pass the parameters as size-1 # arrays, 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] 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 xi.shape != yi.shape: xi, yi = np.broadcast_arrays(xi, yi) # The ePSF value contributes to the flux derivative through its # bilinear weight and to the position derivatives through the # weight derivatives. The chain rule adds the shift terms from # the spline partial derivatives (dxi/dx_0 = -oversampling[1], # dyi/dy_0 = -oversampling[0]). The kernel computes all of these # in one pass over the bounding planes. grid_idx, weights, dw_dx, dw_dy = self._bounding_weights( x_0, y_0, derivs=True) self._fill_spline_coefficients(grid_idx, weights, dw_dx, dw_dy) tx, ty = self._spline_knots[0] xi = np.ascontiguousarray(xi, dtype=float) yi = np.ascontiguousarray(yi, dtype=float) d_flux = np.empty(xi.shape, dtype=float) deriv_x = np.empty(xi.shape, dtype=float) deriv_y = np.empty(xi.shape, dtype=float) bispline_sum_deriv(tx, ty, self._spline_coeffs, grid_idx, weights, dw_dx, dw_dy, float(self.oversampling[1]), float(self.oversampling[0]), xi.ravel(), yi.ravel(), d_flux.ravel(), deriv_x.ravel(), deriv_y.ravel()) d_x_0 = flux * deriv_x d_y_0 = flux * deriv_y if self.fill_value is not None: # Outside the input pixel grid the model is constant # (fill_value), so all derivatives are zero there invalid = _out_of_grid_mask(xi, yi, self.data.shape[1:]) d_flux[invalid] = 0.0 d_x_0[invalid] = 0.0 d_y_0[invalid] = 0.0 return [d_flux, d_x_0, d_y_0] @_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 ePSF model grids stored in the STDPSF format. STDPSF files are FITS files containing a 3D array of ePSF models. The FITS header specifies the fiducial detector coordinates associated with each ePSF in the grid. For STDPSF files, the oversampling factor is assumed to be 4 along both axes. Parameters ---------- filename : str, path-like, file-like, or `~astropy.io.fits.HDUList` The name or URL of a STDPSF FITS file, an open file object, or an already-open `~astropy.io.fits.HDUList`. Examples -------- >>> from photutils.psf import STDPSFGrid >>> psfgrid = STDPSFGrid('STDPSF_ACSWFC_F814W.fits') >>> fig = psfgrid.plot_grid() """ # STDPSF files are assumed to have an oversampling factor of 4 along # both axes _default_oversampling = (4, 4) def __init__(self, filename): grid_data = _read_stdpsf(filename) xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] # itertools.product iterates over the last input first grid_xypos = np.array([yx[::-1] for yx in itertools.product(ygrid, xgrid)]) # Try to get additional metadata from the filename because this # information is not currently available in the FITS headers. meta = _get_metadata(filename, None) or {} self._init_grid(grid_data['data'], grid_xypos, (len(ygrid), len(xgrid)), self._default_oversampling, meta) @classmethod def _from_asdf(cls, data, meta): """ Create a `STDPSFGrid` from the contents of an ASDF file. Parameters ---------- data : `~numpy.ndarray` A 3D array containing the ePSF grid. meta : dict A metadata dictionary. It must contain a ``'grid_xypos'`` key holding an ``(N, 2)`` array of the fiducial ``(x, y)`` detector coordinates and a ``'grid_shape'`` key holding the ``(ny, nx)`` shape of the grid. It may also contain an ``'oversampling'`` key. If absent, the default is ``(4, 4)``. These three keys are consumed here and are not retained in the ``meta`` attribute. Returns ------- result : `STDPSFGrid` The ePSF grid. """ meta = dict(meta) for key in ('grid_xypos', 'grid_shape'): if key not in meta: msg = f'{key!r} must be in the meta dictionary' raise ValueError(msg) grid_xypos = np.asarray(meta.pop('grid_xypos')) grid_shape = meta.pop('grid_shape') oversampling = meta.pop('oversampling', cls._default_oversampling) obj = cls.__new__(cls) obj._init_grid(data, grid_xypos, grid_shape, oversampling, meta) return obj def _init_grid(self, data, grid_xypos, grid_shape, oversampling, meta): """ Set the ePSF grid attributes. Parameters ---------- data : `~numpy.ndarray` A 3D array containing the ePSF grid. grid_xypos : `~numpy.ndarray` An ``(N, 2)`` array of the fiducial ``(x, y)`` detector coordinates of each ePSF, ordered along y and then x. grid_shape : tuple of int The ``(ny, nx)`` shape of the ePSF grid. oversampling : int or array_like of int The integer oversampling factor(s) of the ePSF images. meta : dict The metadata dictionary. """ self._data = data self._grid_xypos = grid_xypos self.meta = meta self._grid_shape = tuple(int(value) for value in grid_shape) # The grid axes are extracted from the first row and column # rather than with np.unique because a coordinate can be # repeated where two detectors abut (e.g., ACS/WFC). xypos = grid_xypos.reshape(*self._grid_shape, 2) self._xgrid = xypos[0, :, 0] self._ygrid = xypos[:, 0, 1] self._oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 0)) @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 matches the first axis of the 3D `~numpy.ndarray` of ePSFs. In other words, ``grid_xypos[i]`` is the (x, y) position of the reference ePSF defined in ``data[i]``. """ return self._grid_xypos @property def grid_shape(self): """ The ``(ny, nx)`` shape of the ePSF grid. """ return self._grid_shape @property def oversampling(self): """ The integer oversampling factor(s) of the input ePSF images. Returns ------- oversampling : `~numpy.ndarray` The oversampling factors in ``(y, x)`` order. """ return self._oversampling @_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 = [] # Use int to avoid printing numpy int64 values in the string # representation oversampling = tuple(int(value) for value in self.oversampling) keys = ('STDPSF', 'instrument', 'detector', 'filter') 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)), ('Grid shape', self.grid_shape), ('PSF shape (oversampled pixels)', self.data.shape[1:]), ('Oversampling', 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-24be529/photutils/psf/groupers.py000066400000000000000000000371111526452531300226560ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for performing grouping of stars. """ from collections import defaultdict from functools import cached_property import numpy as np from scipy.spatial import cKDTree 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 {1: 2, 2: 2, 3: 1} >>> source_groups.sizes array([2, 2, 2, 2, 1]) >>> source_groups.group_centers {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) if not np.isfinite(self.x).all(): msg = 'x coordinates must be finite (no NaN or inf values)' raise ValueError(msg) if not np.isfinite(self.y).all(): msg = 'y coordinates must be finite (no NaN or inf values)' 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 @cached_property 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)) @cached_property def sizes(self): """ Size of each group for each source. Returns ------- 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]) @cached_property 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. Raises ------ ValueError If ``group_id`` is not one of the group IDs. """ 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] # Sample the colormap over its full range so that the group # colors are distinct. Indexing a colormap by the group number # would map every group to the low end of the colormap. fractions = np.arange(self.n_groups) / max(self.n_groups - 1, 1) colors = cmap(fractions) # Merge user label kwargs with the defaults default_label_kwargs = {'ha': 'center', 'va': 'center', 'zorder': 10} label_kwargs = {**default_label_kwargs, **(label_kwargs or {})} # A user-supplied color overrides the per-group colors user_color = kwargs.pop('color', None) # 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 = colors[i] if user_color is None else user_color 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 the connected components of the graph linking pairs of sources separated by less than or equal to ``min_separation``. This is identical to single-linkage hierarchical agglomerative clustering with a distance criterion, but is computed using a KD-tree so that it scales to large numbers of sources. Parameters ---------- min_separation : float The minimum distance (in pixels) such that any two sources separated by less than or equal to 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 {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): if not np.isfinite(min_separation) or min_separation <= 0: msg = 'min_separation must be a positive finite value' raise ValueError(msg) 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 criterion. 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.ndim != 1 or y.ndim != 1: msg = 'x and y must be 1D arrays' raise ValueError(msg) 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) # A single source forms its own group if x.shape == (1,): return np.array([1]) # Find all pairs of sources separated by min_separation or less. # query_pairs includes pairs at exactly min_separation, matching # the inclusive fclusterdata distance criterion. xypos = np.column_stack((x, y)) tree = cKDTree(xypos) pairs = tree.query_pairs(self.min_separation, output_type='ndarray') # Union-find over the pairs to get the connected components, # which for single linkage are identical to the clusters from # hierarchical clustering with a distance criterion. parent = np.arange(len(x)) def find(idx): while parent[idx] != idx: parent[idx] = parent[parent[idx]] idx = parent[idx] return idx for i, j in pairs: root_i = find(i) root_j = find(j) if root_i != root_j: parent[root_j] = root_i cluster_labels = [find(i) for i in range(len(x))] # 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 criterion. 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] """ x = np.atleast_1d(x) y = np.atleast_1d(y) groups = self._compute_groups(x, y) if return_groups_object: return SourceGroups(x, y, groups) return groups astropy-photutils-24be529/photutils/psf/image_models.py000066400000000000000000000663711526452531300234470ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Image-based PSF models. """ import copy import warnings from functools import cached_property import numpy as np from astropy.modeling import Fittable2DModel, Parameter from scipy.interpolate import RectBivariateSpline from photutils.psf._bispline import bispline_sum, bispline_sum_deriv from photutils.psf._bispline_inputs import ONE_PLANE, UNIT_WEIGHT, ZERO_WEIGHT from photutils.psf.utils import _copy_model_sharing_data, _out_of_grid_mask from photutils.utils._parameters import as_pair from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = ['ImagePSF'] class ImagePSF(Fittable2DModel): """ A model representing a 2D image PSF. This class evaluates a 2D image PSF at arbitrary positions, including fractional pixel coordinates, using spline interpolation provided by `~scipy.interpolate.RectBivariateSpline`. This model has three parameters: an image intensity scaling factor (``flux``), which scales the input image, and two positional parameters (``x_0`` and ``y_0``), which specify the location of the feature in the coordinate grid where the model is evaluated. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array containing the ePSF image. The x and y dimensions must both be at least 4 pixels. All values must be finite. By default, the ePSF peak is assumed to be centered in the input image (see ``origin``). See the Notes section for the definition of an ePSF and for details on the required normalization of the input image. flux : float, optional The flux scaling factor. This corresponds to the total source flux, assuming the input ePSF image is properly normalized. x_0, y_0 : float, optional 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 in the input image corresponding to the reference pixel. The reference pixel is placed at the model ``x_0`` and ``y_0`` coordinates in the output coordinate grid. In most cases, the PSF should be centered in the input image, so ``origin`` should be set to the central pixel of ``data``. If `None`, ``origin`` is set to the center of the input image, ``((n_x - 1) / 2, (n_y - 1) / 2)``. oversampling : int or array_like (int), optional The integer oversampling factor(s) of the input PSF image. If a scalar is provided, it is applied to both axes. If two values are provided, they must be in ``(y, x)`` order. fill_value : float or `None`, optional The value used for points outside the input pixel grid. The default is 0.0. If `None`, a point outside the input pixel grid takes the value of the spline at the nearest point on the edge of the grid. **kwargs : dict, optional Additional keyword arguments passed to the `~astropy.modeling.Model` base class. See Also -------- GriddedPSFModel : A model for a grid of ePSF models. make_epsf_from_psf : Make an ePSF image from a sampled PSF. Notes ----- The input image must be an effective PSF (ePSF). Each value of an ePSF is the fraction of the source flux that falls in a whole detector pixel centered at that position relative to the source, even when the image is oversampled. The model interpolates the input image and does not integrate it over the detector pixels. Evaluating the model at a position ``(x, y)`` therefore gives the flux in a detector pixel centered at ``(x, y)``, which can be any fractional pixel position. Because each value is the flux in a whole detector pixel, the model values sum to ``flux`` only when the model is evaluated on a grid with a spacing of one detector pixel. That holds for any values of ``x_0`` and ``y_0``. On a finer grid the pixels overlap, and the sum is larger than ``flux`` by the ratio of the pixel area to the area of a grid cell. An oversampled image that is not an ePSF, such as a PSF sampled at the points of a fine grid or binned into subpixels, is not converted to an ePSF by this model. The model is then sharper than a source in the data. For an undersampled PSF, the sum of the model values over the detector pixels can also change with the subpixel position of the source. Such an image should first be integrated over the area of a detector pixel centered at each of its grid points, which is what `make_epsf_from_psf` does. The fitted ``flux`` parameter represents the total source flux, provided the input ePSF image is properly normalized. The fitted flux is a multiplicative scale factor applied to the input ePSF after accounting for any oversampling. For a fully sampled ePSF (i.e., no oversampling), the sum of the ePSF values over an infinite grid is 1.0. Because ePSFs are represented by finite images in practice, the sum of the array values may be less than 1.0. For oversampled ePSF images, the normalization should instead be such that the sum of the array values over an infinite grid equals the product of the oversampling factors (e.g., ``oversampling**2`` when the oversampling is the same along both axes). Again, a finite image will generally have a smaller sum because it does not contain the full PSF wings. If the input ePSF image covers only a finite region of the PSF, correction factors based on the encircled or ensquared energy can be used to estimate the missing flux and obtain the proper normalization. The model is zero outside of the input image. The model values on a grid of detector pixels therefore sum to less than ``flux`` by the fraction of the source flux that is outside of the image. That sum also changes with the subpixel position of the source, because the number of detector pixels inside the image changes (see :ref:`psf-image-models` for example values). The input image should therefore be large enough that the ePSF is small at its edges. The fluxes and positions fitted by the PSF photometry classes are not affected if the fitted region of each source is well inside the image, but the model and residual images that they make do not include the flux outside of it. Examples -------- In this simple example, we create a PSF image model from a circular Gaussian that is integrated over the pixels, which is an ePSF with no oversampling. In this case, one should use the `CircularGaussianPRF` 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 CircularGaussianPRF, ImagePSF gaussian_prf = CircularGaussianPRF(x_0=12, y_0=12, fwhm=3.2) yy, xx = np.mgrid[:25, :25] psf_data = gaussian_prf(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') An oversampled PSF whose values are samples of the PSF, such as the output of an optical model, must be converted to an ePSF before it is used as the input image. Here, a narrow Gaussian PSF is sampled on a grid that is oversampled by a factor of 4 and then integrated over the detector pixels with `make_epsf_from_psf`: >>> import numpy as np >>> from photutils.psf import ImagePSF, make_epsf_from_psf >>> oversampling = 4 >>> yy, xx = np.mgrid[-30:31, -30:31] / oversampling >>> sigma = 0.5 # detector pixels >>> psf = np.exp(-(xx**2 + yy**2) / (2 * sigma**2)) >>> psf *= oversampling**2 / psf.sum() >>> epsf = make_epsf_from_psf(psf, oversampling=oversampling) >>> model = ImagePSF(epsf, oversampling=oversampling, x_0=0.3, ... y_0=-0.4) The model values on a grid of detector pixels sum to the model flux for any subpixel position of the source: >>> yy, xx = np.mgrid[-7:8, -7:8] >>> print(f'{model(xx, yy).sum():.3f}') 1.000 """ 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.data = data self.origin = origin self.oversampling = oversampling self.fill_value = fill_value if type(self).interpolator is not ImagePSF.interpolator: msg = ('Overriding the ImagePSF.interpolator attribute in a ' 'subclass is deprecated since version 3.1 and will be ' 'removed in version 4.0.') warnings.warn(msg, PhotutilsDeprecationWarning, stacklevel=2) super().__init__(flux, x_0, y_0, **kwargs) def __setattr__(self, name, value): if name == 'interpolator': msg = ('Assigning a custom interpolator to the ' 'ImagePSF.interpolator attribute is deprecated since ' 'version 3.1 and will be removed in version 4.0.') warnings.warn(msg, PhotutilsDeprecationWarning, stacklevel=2) # The model calls an assigned interpolator instead of # evaluating the spline of the image data with the kernel. self.__dict__['_interpolator_assigned'] = True super().__setattr__(name, value) @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) # The minimum number of data points required is 4 along each # axis. This is because RectBivariateSpline requires at least 4 # points along each axis for cubic spline interpolation (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', tuple(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. The cached spline is one of the shared attributes. It is built on this model first, if it is not already cached, so that every copy shares it instead of building its own. 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. """ if not self._has_custom_interpolator: _ = self._spline return _copy_model_sharing_data(self) 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 data(self): """ The 2D image of the PSF. Setting this attribute revalidates the input array and discards the cached `interpolator`. The `origin` is not updated, so it should be reset explicitly if the new image has a different shape or a different reference pixel. The cached `interpolator` is not updated if the array is modified in place (e.g., ``model.data[:] = values``) after the model has been evaluated or copied with `copy`. Assign a new array to this attribute instead. """ return self._data @data.setter def data(self, value): """ Set the 2D image of the PSF. Parameters ---------- value : 2D `~numpy.ndarray` The 2D image of the PSF. """ self._validate_data(value) self._data = value # Discard the cached interpolators, which are tied to the old # data. That includes an interpolator assigned to the model. self.__dict__.pop('interpolator', None) self.__dict__.pop('_interpolator_assigned', None) self.__dict__.pop('_deriv_interpolators', None) self.__dict__.pop('_spline', None) @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 oversampling(self): """ 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. """ return self._oversampling @oversampling.setter def oversampling(self, value): """ Set the oversampling factor(s) of the input PSF image. Parameters ---------- value : int or tuple of int 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. """ self._oversampling = as_pair('oversampling', value, lower_bound=(0, 0)) @property def origin(self): """ The (x, y) pixel coordinates, as a 1D `~numpy.ndarray`, of the origin of the coordinate system within the model image. 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 (``(n_pixels - 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 @cached_property 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 ----- The model evaluates the knots and coefficients of this spline with a compiled kernel, which also computes the partial derivatives for `fit_deriv`. The spline object itself is not called. .. deprecated:: 3.1 Defining a custom interpolator, either by overriding this property in a subclass or by assigning an interpolator to this attribute of a model, is deprecated and will be removed in version 4.0. A model with a custom interpolator calls it instead of using the compiled kernel. An assigned interpolator is discarded when `data` is set. The custom interpolator must provide a `~scipy.interpolate.RectBivariateSpline`-compatible ``partial_derivative`` method to support `fit_deriv`. Otherwise, ``fit_deriv`` should also be set to `None` to fall back to the fitter's finite-difference Jacobian. """ 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) @property def _has_custom_interpolator(self): """ Whether this model has a custom interpolator. A custom interpolator is one defined by a subclass that overrides `interpolator`, or one that was assigned to the ``interpolator`` attribute of this model. Such a model calls its interpolator. Otherwise, the spline built from the image data is evaluated by the compiled kernel. """ return (type(self).interpolator is not ImagePSF.interpolator or self.__dict__.get('_interpolator_assigned', False)) @cached_property def _spline(self): """ The knots and coefficients of the `interpolator` spline, as the ``(tx, ty, coeffs)`` tuple that the compiled kernel takes. The arrays are the ones that the spline object holds, not copies of them. """ interp = self.interpolator tx, ty = interp.get_knots() return (np.ascontiguousarray(tx, dtype=float), np.ascontiguousarray(ty, dtype=float), np.ascontiguousarray(interp.get_coeffs(), dtype=float)) @cached_property def _deriv_interpolators(self): """ The spline partial-derivative interpolators of a custom interpolator. The interpolators evaluate the partial derivatives of `interpolator` with respect to its first (x) and second (y) variables. They are precomputed here because evaluating them is faster than passing ``dx=1`` or ``dy=1`` to `interpolator`, which computes the derivative on the fly. They are used by `fit_deriv`. """ return (self.interpolator.partial_derivative(1, 0), self.interpolator.partial_derivative(0, 1)) def _precompute_interpolators(self): """ Compute and cache the interpolators of a custom interpolator. The cached interpolators are shared by the model copies made with `copy` (e.g., by the fitters), so calling this method before fitting the model to many sources builds the splines once instead of once per copy. The derivative interpolators are computed only when `fit_deriv` is enabled. A model without a custom interpolator needs no such step, because `copy` builds its spline. """ if not self._has_custom_interpolator: return _ = self.interpolator if self.fit_deriv is not None: _ = self._deriv_interpolators def _calc_bounding_box(self): """ Return 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 = (self.data.shape[1] - 1) / 2 - self.origin[0] yshift = (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 -------- >>> import numpy as np >>> 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 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. """ # Promote scalar inputs to 1D arrays so that the interpolator # returns an array that supports masked assignment below, # regardless of the scipy version. x = np.atleast_1d(np.asarray(x, dtype=float)) y = np.atleast_1d(np.asarray(y, dtype=float)) xi = self.oversampling[1] * (x - x_0) yi = self.oversampling[0] * (y - y_0) xi += self._origin[0] yi += self._origin[1] if xi.shape != yi.shape: xi, yi = np.broadcast_arrays(xi, yi) if self._has_custom_interpolator: evaluated_model = flux * self.interpolator(xi, yi, grid=False) else: tx, ty, coeffs = self._spline values = np.empty(xi.shape, dtype=float) bispline_sum(tx, ty, coeffs[np.newaxis], ONE_PLANE, UNIT_WEIGHT, xi.ravel(), yi.ravel(), values.ravel()) # The flux may have units or be an array, so the product # cannot be stored in the plain array of the kernel output evaluated_model = flux * values if self.fill_value is not None: # Set pixels that are outside the input pixel grid to the # fill_value to avoid extrapolation invalid = _out_of_grid_mask(xi, yi, self.data.shape) evaluated_model[invalid] = self.fill_value return evaluated_model def fit_deriv(self, x, y, flux, x_0, y_0): """ Calculate the partial derivatives of the image model with respect to the model parameters. Providing this analytic Jacobian allows the fitter to avoid the finite-difference approximation, which requires additional model evaluations. 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 : list of `~numpy.ndarray` The list of partial derivatives with respect to the ``flux``, ``x_0``, and ``y_0`` parameters. """ # Promote scalar inputs to 1D arrays so that the interpolator # returns an array that supports masked assignment below, # regardless of the scipy version x = np.atleast_1d(np.asarray(x, dtype=float)) y = np.atleast_1d(np.asarray(y, dtype=float)) xi = self.oversampling[1] * (x - x_0) yi = self.oversampling[0] * (y - y_0) xi += self._origin[0] yi += self._origin[1] if xi.shape != yi.shape: xi, yi = np.broadcast_arrays(xi, yi) # The spline interpolation is linear in flux, and the chain rule # gives the x_0 and y_0 derivatives from the spline partial # derivatives (dxi/dx_0 = -oversampling[1], dyi/dy_0 = # -oversampling[0]) if self._has_custom_interpolator: dx_interp, dy_interp = self._deriv_interpolators d_flux = self.interpolator(xi, yi, grid=False) d_x_0 = (-flux * self.oversampling[1] * dx_interp(xi, yi, grid=False)) d_y_0 = (-flux * self.oversampling[0] * dy_interp(xi, yi, grid=False)) else: tx, ty, coeffs = self._spline d_flux = np.empty(xi.shape, dtype=float) deriv_x = np.empty(xi.shape, dtype=float) deriv_y = np.empty(xi.shape, dtype=float) bispline_sum_deriv(tx, ty, coeffs[np.newaxis], ONE_PLANE, UNIT_WEIGHT, ZERO_WEIGHT, ZERO_WEIGHT, float(self.oversampling[1]), float(self.oversampling[0]), xi.ravel(), yi.ravel(), d_flux.ravel(), deriv_x.ravel(), deriv_y.ravel()) # The flux may have units or be an array, so the products # cannot be stored in the plain arrays of the kernel output d_x_0 = flux * deriv_x d_y_0 = flux * deriv_y if self.fill_value is not None: # Outside the input pixel grid the model is constant # (fill_value), so all derivatives are zero there invalid = _out_of_grid_mask(xi, yi, self.data.shape) d_flux[invalid] = 0.0 d_x_0[invalid] = 0.0 d_y_0[invalid] = 0.0 return [d_flux, d_x_0, d_y_0] astropy-photutils-24be529/photutils/psf/iterative.py000066400000000000000000001146671526452531300230200ustar00rootroot00000000000000# 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._parameters import as_pair 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. The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. The model is evaluated at the center of each pixel, and the result is compared with the pixel value. The value of the model at a position must therefore be the flux in a pixel centered at that position, as for the analytic models whose names end in ``PRF`` and for an image-based model made from an ePSF (see :ref:`psf-terminology`). A model that is not integrated over the pixels gives biased fluxes and positions for undersampled data. The analytic models whose names end in ``PSF`` (e.g., `~photutils.psf.CircularGaussianPSF` and `~photutils.psf.MoffatPSF`) are not integrated over the pixels and should not be used here. Each has a counterpart whose name ends in ``PRF`` (e.g., `~photutils.psf.CircularGaussianPRF` and `~photutils.psf.MoffatPRF`). With a ``PSF`` model whose shape parameters are fixed, the fitted flux is biased by a few percent even for a FWHM of 2 to 3 pixels. The fitted source positions are the fitted values of the model position parameters. For an image-based model, they give the position of the center of the model image, or of the ``origin`` of an `ImagePSF`. The PSF must therefore be centered on that point of the model image. Otherwise, the fitted positions are all offset by the distance of the PSF center from that point. 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 a positive odd number. In general, ``fit_shape`` should be set to a small size (e.g., ``(5, 5)``) that covers the region with the highest flux signal-to-noise. finder : callable or `~photutils.detection.StarFinderBase` A callable used to identify sources in an image. This is a required input for `IterativePSFPhotometry`. The ``finder`` must accept a 2D image as the first argument and a ``mask`` keyword argument, 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'``. 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`. 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. In the 'all' mode, the local background values of previously fit sources are carried into subsequent iterations. 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, optional 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 a positive 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. n_threads : int, optional The number of threads used to fit the sources. The default is 1 (no multithreading). When ``n_threads`` > 1, the source groups are divided into chunks that are fitted concurrently. Each group is fitted independently, so the results are identical to the single-threaded computation. Every thread fits with its own copy of the PSF model and the ``fitter``, so the input ``fitter`` is not called and its ``fit_info`` is not updated. The fitting runs mostly in Python code that holds the global interpreter lock (GIL), so multithreading speeds up the fitting only on a free-threaded Python build. When the GIL is enabled, a warning is issued and the sources are fitted in a single thread. 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. Attributes ---------- results : `~astropy.table.QTable` or `None` The table of fit results from the most recent call, or `None` if the instance has not yet been run or no sources were found. fit_results : list of `PSFPhotometry` A list with one `PSFPhotometry` instance per fitting iteration of the most recent call, each holding the results of that iteration. 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 latter 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 the 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. In the 'all' mode, sources whose fitted parameters are nonfinite in one iteration are excluded from subsequent iterations. Such sources therefore appear in the final ``results`` table (as NaN rows) only if they became invalid in the last iteration. In the 'new' mode, invalid sources are always kept as NaN rows. Care should be taken in defining the source groups. Simultaneously fitting very large source groups is computationally expensive and error-prone, because the number of fitted parameters grows with the group size. A warning will be raised if the number of sources in a group exceeds the ``group_warning_threshold`` value. This class stores per-call state on the instance (e.g., ``results`` and ``fit_results``), so a single instance must not be called concurrently from multiple threads. Create one instance per thread for concurrent use. Sharing a single Astropy fitter instance across concurrently-used objects is also unsafe because Astropy fitters store ``fit_info`` on themselves. """ @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, n_threads=1, 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, n_threads=n_threads, 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 if sub_shape is None: self.sub_shape = sub_shape else: self.sub_shape = as_pair('sub_shape', sub_shape, lower_bound=(0, 0), check_odd=True) 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', 'group_warning_threshold', 'sub_shape', 'n_threads', '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, 'group_warning_threshold': self._psfphot.group_warning_threshold, 'n_threads': self._psfphot.n_threads, 'progress_bar': self._psfphot.progress_bar, } return make_repr(self, params, overrides=overrides) @staticmethod def _validate_maxiters(maxiters): if (isinstance(maxiters, bool) or not isinstance(maxiters, (int, float, np.number)) or not np.isfinite(maxiters) or maxiters <= 0): 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 int(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. 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` A new table with the column moved. If either column is not in the table, or the two names are the same, the input table is returned unchanged. """ 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``. If ``sources`` has a ``local_bkg`` column, each finite value times the aperture area is subtracted from the flux. 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. """ local_bkg = None if 'local_bkg' in sources.colnames: local_bkg = np.asarray(sources['local_bkg'].value) flux = self._psfphot._data_processor.get_aper_fluxes( data, mask, sources, local_bkg=local_bkg) 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 initial-parameters table made from the previous iteration's fit results (the fitted values in ``_init`` columns), for the sources fit so far. 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] # Carry the local background values into the next iteration if 'local_bkg' in orig_sources.colnames: prepared_orig['local_bkg'] = orig_sources['local_bkg'] # 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 # Estimate the local background for the new sources from the # residual data. If no estimator was input, use zero, matching # the values assigned by PSFPhotometry. if 'local_bkg' in prepared_orig.colnames: estimator = self._psfphot.local_bkg_estimator if estimator is None: local_bkg = np.zeros(len(new_sources)) unit = getattr(prepared_orig['local_bkg'], 'unit', None) if unit is not None: local_bkg <<= unit else: x_col = param_mapper.init_colnames['x'] y_col = param_mapper.init_colnames['y'] local_bkg = estimator(residual_data, new_sources[x_col], new_sources[y_col], mask=mask) new_sources['local_bkg'] = local_bkg # 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): PSFPhotometry._check_nddata_kwargs(mask=mask, error=error) 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: warnings.simplefilter('always') 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 emit any warnings again if phot_tbl is None: self._emit_warnings(rwarn0) return None residual_data = data with warnings.catch_warnings(record=True) as rwarn1: warnings.simplefilter('always') 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: 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) # No new sources detected if new_sources is None or len(new_sources) == 0: 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': prev_results = self._psfphot.results prev_init = PSFPhotometry._results_to_init_params( prev_results, remove_invalid=False) keep = PSFPhotometry._finite_fit_mask(prev_init) prev_init = prev_init[keep] prev_init['id'] = np.arange(1, len(prev_init) + 1) if 'local_bkg' in prev_results.colnames: prev_init['local_bkg'] = ( prev_results['local_bkg'][keep]) iter_detected = iter_detected[keep] init_params = self._prepare_next_iteration_sources( residual_data, mask, new_sources, prev_init) residual_data = data # Keep track of the iteration number in which the # source was detected current_iter = np.full(len(new_sources), iter_num, dtype=int) 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 self.results = phot_tbl # Emit unique warnings recorded_warnings = rwarn0 + rwarn1 self._emit_warnings(recorded_warnings) return phot_tbl def results_to_init_params(self, *, remove_invalid=True, reset_ids=True): """ Create a table of initial parameters from the fitted 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 nonfinite 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`. Returns ------- init_params_tbl : `~astropy.table.QTable` or `None` The table of initial parameters, or `None` if the instance has not yet been run. """ 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 nonfinite 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`. Returns ------- model_params_tbl : `~astropy.table.QTable` or `None` The table of model parameters, or `None` if the instance has not yet been run. """ 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 names (strings). Default is `False`. Returns ------- decoded : dict A dictionary mapping each source id from the results table to the list of its active flag names (or bit values). If no flags are set for a source, its value is an empty list. The entries follow the results-table order. .. versionchanged:: 3.1 The result is now a dictionary keyed by source id. Previously, a positional list of lists was returned. 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) >>> for source_id, flags in photometry.decode_flags().items(): ... print(f'Source {source_id}: {flags}') # doctest: +SKIP Source 1: [] Source 2: ['non_positive_flux'] """ if self.results is None: msg = ('No results available. Please run the ' 'IterativePSFPhotometry instance first.') raise ValueError(msg) decoded = decode_psf_flags(self.results['flags'], return_bit_values=return_bit_values) return {int(id_): flags for id_, flags in zip(self.results['id'], decoded, strict=True)} 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 ' '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 self.results is None: 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-24be529/photutils/psf/matching/000077500000000000000000000000001526452531300222255ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf/matching/__init__.py000066400000000000000000000024051526452531300243370ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Deprecated subpackage. Use ``photutils.psf_matching`` instead. .. deprecated:: 3.0 The ``photutils.psf.matching`` subpackage is deprecated and will be removed in version 4.0. Use ``photutils.psf_matching`` instead. """ import warnings import photutils.psf_matching as _psf_matching from photutils.psf_matching.fourier import __all__ as _fourier_all from photutils.psf_matching.utils import __all__ as _utils_all from photutils.psf_matching.windows import __all__ as _windows_all from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = list(_fourier_all) + list(_utils_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, PhotutilsDeprecationWarning, 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-24be529/photutils/psf/model_helpers.py000066400000000000000000000741371526452531300236430ustar00rootroot00000000000000# 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 scipy.interpolate import make_interp_spline from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._parameters import as_pair from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = ['grid_from_epsfs', 'make_epsf_from_psf', 'make_psf_model'] @deprecated(since='3.1', message=('make_psf_model was deprecated in version 3.1 and will ' 'be removed in version 4.0. Use one of the PSF models ' 'provided by photutils.psf, an ImagePSF made from the ' 'output of make_epsf_from_psf, or a custom astropy ' 'Fittable2DModel with x_0, y_0, and flux parameters ' 'instead.'), warning_type=PhotutilsDeprecationWarning) 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. The output PSF model is evaluated at the input positions like the input ``model``. It is not integrated over the detector pixels (see the Notes section). 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 ``offset_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 ``offset_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 flux parameter will be added. It is called ``amplitude_3`` unless ``model`` is a compound model, in which case the number is the number of submodels in ``model`` plus 2. The name is stored in the ``flux_name`` attribute of the output model. normalize : bool, optional If `True`, the input ``model`` will be integrated and rescaled so that it 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 : int, optional The size of the integration grid in x and y for normalization. Must be positive. 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. All parameters of the output model other than the x, y, and flux parameters are fixed, so that they are not fit by the PSF photometry classes. Notes ----- To normalize the model, by default it is evaluated on a grid of ``dx * subsample`` by ``dy * subsample`` points that spans ``dx - 1`` by ``dy - 1`` pixels centered on the model. The model is then integrated over that 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 nonzero model. This can happen when the model function is sharply localized relative to the size of the integration interval. A `ValueError` is raised if the integral is zero. That integration is used only to normalize the model. The values of the output model on a grid of detector pixels are the values of the input ``model`` at the pixel centers, not the fluxes in the pixels. For a PSF that is undersampled by the detector pixels, such a model is sharper than a source in the data and the sum of its values over the pixels depends on the subpixel position of the source. To integrate a model over the pixels, evaluate it on an oversampled grid, make an ePSF image from the result with `make_epsf_from_psf`, and use that image with `ImagePSF`. 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') # doctest: +SKIP >>> 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') # The _InverseShift model is always the first submodel, so the x # position parameter name is always "offset_0". 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') # The _InverseShift model is always the second submodel, so the y # position parameter name is always "offset_1". 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') # The Const2D model is always the last submodel, so the flux # parameter name is always "amplitude_3" (or "amplitude" if the # input model is a CompoundModel). flux_name = psf_model.param_names[-1] else: if flux_name not in input_model.param_names: msg = f'{flux_name!r} parameter name not found in the input model' raise ValueError(msg) 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') # Set all the other parameters to be fixed so that they are not fit # during PSF photometry. for name in psf_model.param_names: psf_model.fixed[name] = name not in (x_name, y_name, flux_name) # 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 evaluated on a grid of ``dx * subsample`` by ``dy * subsample`` points that spans ``dx - 1`` by ``dy - 1`` pixels centered on the model. The model is then integrated over that 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 nonzero 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 : int, optional The size of the integration grid in x and y. Must be positive. 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 nx_pts = int(dx * subsample) ny_pts = int(dy * subsample) xvals = np.linspace(xc - hx, xc + hx, nx_pts) yvals = np.linspace(yc - hy, yc + hy, ny_pts) # 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 y because # each row varies over y, then over x) int_func = trapezoid return int_func([int_func(row, yvals) for row in data], xvals) 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]) + shift) 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`', warning_type=PhotutilsDeprecationWarning) 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 the same for each input ePSF, and values for ``flux``, ``oversampling``, and ``fill_value`` must match as well. The ``flux`` and ``origin`` of the input models are not passed on. The output model has a ``flux`` of 1 and takes the center of each image as the reference pixel. 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. The input dictionary is modified in place. Default is None. Returns ------- model : `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 that ``grid_xypos`` has 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) unitt = None with contextlib.suppress(AttributeError): unitt = epsf.data.unit if unitt != dat_unit: msg = 'All input data must have the same unit' raise ValueError(msg) 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.any(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 fiducials. # 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) def _integrate_pixel_along_axis(data, half_width, axis, *, midpoints=False): """ Integrate the cubic spline through ``data`` over a window centered at each grid point along one axis, or at each point midway between adjacent grid points. Parameters ---------- data : `~numpy.ndarray` The data array. half_width : float The half width of the integration window in grid points. axis : int The axis along which to integrate. midpoints : bool, optional Whether to center the windows midway between adjacent grid points instead of at the grid points. Returns ------- result : `~numpy.ndarray` The integrals, with the same shape as ``data``, or with one fewer point along ``axis`` if ``midpoints`` is `True`. The window is truncated at the first and last grid points. """ n_points = data.shape[axis] points = np.arange(n_points, dtype=float) antiderivative = make_interp_spline(points, data, k=3, axis=axis).antiderivative() centers = points[:-1] + 0.5 if midpoints else points lower = np.clip(centers - half_width, 0, n_points - 1) upper = np.clip(centers + half_width, 0, n_points - 1) return antiderivative(upper) - antiderivative(lower) def make_epsf_from_psf(data, *, oversampling, midpoints=False): """ Make an effective PSF (ePSF) image from an oversampled PSF image that is not integrated over the detector pixels. The image-based PSF models (`ImagePSF` and `GriddedPSFModel`) require ePSF images. Each value of an ePSF is the fraction of the source flux that falls in a whole detector pixel centered at that position relative to the source. This function makes such an image from an oversampled PSF whose values are samples of the PSF at the grid points, such as the output of an optical model. Parameters ---------- data : 2D or 3D `~numpy.ndarray` The oversampled PSF image. A 3D array is a stack of PSF images with shape ``(n_psfs, ny, nx)``, such as the data of a `GriddedPSFModel`. The x and y dimensions must both be at least 4 pixels. All values must be finite. A masked array with masked values is not allowed. The units of a `~astropy.units.Quantity` are dropped. oversampling : int or array_like (int) The integer oversampling factor(s) of the PSF image. If a scalar is provided, it is applied to both axes. If two values are provided, they must be in ``(y, x)`` order. midpoints : bool, optional Whether to make the ePSF at the points midway between the input grid points along each axis, instead of at the input grid points. The output image then has one fewer point along each axis than ``data``. Use this option for a PSF that is centered on an image with an even number of points along each axis, which is what an optical model typically returns for an even oversampling factor. The PSF center is then between the four central grid points of ``data``. With ``midpoints=True`` the output image has an odd number of points along each axis and the PSF center is on its central grid point, like the ePSFs that `EPSFBuilder` makes. That is convenient for comparing or combining the result with other ePSFs and for displaying it. The accuracy is the same for either grid, because the same spline is integrated. This option is not needed to use the result with `ImagePSF` or `GriddedPSFModel`. Both accept an image with an even number of points, whose center is between grid points. Returns ------- result : `~numpy.ndarray` The ePSF image(s), with the same oversampling as ``data``. The shape and grid are also the same as ``data`` unless ``midpoints`` is `True`. The result is always a plain array without units. See Also -------- ImagePSF, GriddedPSFModel Notes ----- The input image is interpolated with a bicubic spline. The spline is integrated exactly over the area of one detector pixel centered at each output grid point, and the result is divided by the number of grid points in a detector pixel. The output values are therefore on the same scale as the input values, and the output image is not normalized. Normalize the input image, not the output image. `ImagePSF` requires an image whose values would sum to the product of the oversampling factors if the image contained all of the PSF. The values of an image that contains only part of the PSF should sum to that product times the fraction of the flux that is inside the image. If the input image is scaled that way, the output values are correctly scaled. The output image has the same sum as the input image if the PSF is negligible at the image edges. Otherwise its sum is slightly smaller, because of the edge values described below, and normalizing the output image would make all of its values too large. The accuracy of the result is set by how well the spline through the input values represents the PSF. The input grid must therefore sample the PSF well, which generally requires an oversampled image for a PSF that is undersampled by the detector pixels. The function does not move the PSF within the image. The point at the center of the input image is also at the center of the output image, for either value of ``midpoints``. `GriddedPSFModel`, and `ImagePSF` by default, take the center of the image as the position of the source. The PSF should therefore be centered on the input image. Otherwise, the source positions fitted with the model are all offset by the distance of the PSF center from the image center. For `ImagePSF`, the ``origin`` keyword can be used instead to give the position of the PSF center in the ePSF image. That position is 0.5 smaller along each axis than in the input image if ``midpoints`` is `True`. The input image is taken to be zero outside of its grid. The output values within half of a detector pixel of the image edges are therefore integrals over only the part of the pixel that is inside the grid. They are smaller than the ePSF values at those positions, by a factor of about two at an edge and about four at a corner. If ``midpoints`` is `True`, no output point is on an edge and the factors are smaller. All other output values are unaffected. These values matter for PSF photometry only if the fitted region of a source extends to within half of a detector pixel of the edge of the model image. They are not removed from the output image, because a model made from the trimmed image contains less of the source flux and conserves flux no better. The input image should be large enough that the PSF is small at its edges. The input values must be samples of the PSF at the grid points. The result is less accurate for an image whose values are the fluxes in the cells of the oversampled grid, because such an image is already integrated over the area of one cell. A model made from the output image conserves flux. The model values on a grid of detector pixels sum to the model flux for any subpixel position of the source, apart from the flux that falls outside of the image. A model made directly from a PSF that is sampled at points does not have this property when the PSF is undersampled by the detector pixels. Examples -------- Make an ePSF from a narrow Gaussian PSF that is sampled at the points of a grid that is oversampled by a factor of 5: >>> import numpy as np >>> from photutils.psf import ImagePSF, make_epsf_from_psf >>> oversampling = 5 >>> yy, xx = np.mgrid[-37:38, -37:38] / oversampling >>> sigma = 0.42 # detector pixels >>> psf = np.exp(-(xx**2 + yy**2) / (2 * sigma**2)) >>> psf *= oversampling**2 / psf.sum() >>> epsf = make_epsf_from_psf(psf, oversampling=oversampling) The sum of the `ImagePSF` model over the detector pixels depends on the subpixel position of the source for the sampled PSF, but not for the ePSF: >>> yy, xx = np.mgrid[-7:8, -7:8] >>> for name, data in (('psf', psf), ('epsf', epsf)): ... model = ImagePSF(data, oversampling=oversampling) ... for x_0 in (0.0, 0.25, 0.5, 0.75): ... model.x_0 = x_0 ... print(f'{name}, x_0={x_0:.1f}: {model(xx, yy).sum():.3f}') psf, x_0=0.0: 1.127 psf, x_0=0.2: 1.062 psf, x_0=0.5: 0.997 psf, x_0=0.8: 1.062 epsf, x_0=0.0: 1.000 epsf, x_0=0.2: 1.000 epsf, x_0=0.5: 1.000 epsf, x_0=0.8: 1.000 An optical model with an even oversampling factor typically returns an image with an even number of points, with the PSF centered between the four central grid points. Use ``midpoints=True`` to make an ePSF with the PSF center on its central grid point: >>> oversampling = 4 >>> yy, xx = (np.mgrid[0:60, 0:60] - 29.5) / oversampling >>> psf = np.exp(-(xx**2 + yy**2) / (2 * sigma**2)) >>> psf *= oversampling**2 / psf.sum() >>> epsf = make_epsf_from_psf(psf, oversampling=oversampling, ... midpoints=True) >>> print(epsf.shape) (59, 59) >>> print(epsf.argmax() == epsf.size // 2) True """ if np.ma.is_masked(data): msg = 'data must not have masked values' raise ValueError(msg) data = np.asarray(data, dtype=float) if data.ndim not in (2, 3): msg = 'data must be a 2D or 3D array' raise ValueError(msg) if data.shape[-2] < 4 or data.shape[-1] < 4: msg = 'The x and y dimensions of data must both be at least 4' raise ValueError(msg) if not np.all(np.isfinite(data)): msg = 'All elements of data must be finite' raise ValueError(msg) oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 0)) # Integrate a stack one image at a time. The spline coefficients # of the whole stack would use several times the memory of data. images = data if data.ndim == 3 else data[np.newaxis] n_trim = int(midpoints) result = np.empty((images.shape[0], images.shape[1] - n_trim, images.shape[2] - n_trim)) for idx, image in enumerate(images): for axis, factor in zip((-2, -1), oversampling, strict=True): image = _integrate_pixel_along_axis(image, factor / 2, axis, midpoints=midpoints) image /= factor result[idx] = image return result if data.ndim == 3 else result[0] astropy-photutils-24be529/photutils/psf/model_io.py000066400000000000000000000624551526452531300226100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for reading PSF models. """ import io import itertools import os import re 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'] # Filename extensions recognized as FITS files _FITS_EXTENSIONS = ('.fits', '.fits.gz', '.fit', '.fit.gz', '.fts', '.fts.gz') # Pattern matching the WebbPSF detector-position header keywords _DET_YX_PATTERN = re.compile(r'DET_YX(\d+)$') # Mapping of the STDPSF filename detector field to the (instrument, # detector) metadata values _STDPSF_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')} 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 a GriddedPSFModel and list supported formats >>> GriddedPSFModel.read.help() >>> # Get detailed help on the STDPSF 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): # Use 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, path-like, file-like, or `~astropy.io.fits.HDUList` The name of the STDPSF FITS file, an open file object, or an already-open `~astropy.io.fits.HDUList`. Returns ------- data : dict A dictionary containing the ePSF data and metadata. """ if isinstance(filename, os.PathLike): filename = os.fspath(filename) is_hdulist = isinstance(filename, fits.HDUList) if isinstance(filename, io.IOBase): # A file object created from a file descriptor has an int name name = getattr(filename, 'name', None) is_fileobj = (isinstance(name, str) and name.lower().endswith(_FITS_EXTENSIONS)) else: is_fileobj = False is_fits_ext = (isinstance(filename, str) and filename.lower().endswith(_FITS_EXTENSIONS)) if is_hdulist or is_fileobj or is_fits_ext: return _read_fits_stdpsf(filename) msg = 'This interface supports only FITS files.' raise TypeError(msg) def _read_fits_stdpsf(filename): """ Read a STScI standard-format ePSF (STDPSF) FITS file. Parameters ---------- filename : str, path-like, file-like, or `~astropy.io.fits.HDUList` The name of the STDPSF FITS file, an open file object, or an already-open `~astropy.io.fits.HDUList`. Returns ------- data : dict A dictionary containing the ePSF data and metadata. """ with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) if isinstance(filename, fits.HDUList): header = filename[0].header data = filename[0].data else: with fits.open(filename, ignore_missing_end=True) as hdulist: header = hdulist[0].header data = hdulist[0].data try: n_psfs = header['NAXIS3'] nx_grid = header['NXPSFS'] ny_grid = header['NYPSFS'] except KeyError as exc: msg = 'Invalid STDPSF FITS file' raise ValueError(msg) from exc if 'IPSFX01' in header: xgrid = [header[f'IPSFX{i:02d}'] for i in range(1, nx_grid + 1)] ygrid = [header[f'JPSFY{i:02d}'] for i in range(1, ny_grid + 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) # STDPSF FITS positions are 1-indexed xgrid = np.array(xgrid) - 1 ygrid = np.array(ygrid) - 1 # ny_grid, nx_grid, 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, 'n_psfs': n_psfs, 'nx_grid': nx_grid, 'ny_grid': ny_grid, '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 ----- The STDPSF files that contain multiple detectors are: * 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'] n_psfs = grid_data['n_psfs'] nx_grid = grid_data['nx_grid'] ny_grid = grid_data['ny_grid'] xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] nx_det = detector_data['nx_det'] ny_det = detector_data['ny_det'] det_map = detector_data['det_map'] det_size = detector_data['det_size'] ii = np.arange(n_psfs).reshape((ny_grid, nx_grid)) nx_grid //= nx_det ny_grid //= ny_det n_detectors = nx_det * ny_det ii = reshape_as_blocks(ii, (ny_grid, nx_grid)) ii = ii.reshape(n_detectors, n_psfs // n_detectors) # Map detector_id to index det_idx = det_map[detector_id] idx = ii[det_idx] data = data[idx] xp = det_idx % nx_det i0 = xp * nx_grid i1 = i0 + nx_grid xgrid = xgrid[i0:i1] - xp * det_size ygrid = (ygrid[:ny_grid] if det_idx < nx_det else ygrid[ny_grid:] - 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: # Avoid writing through the reshape view into the input grid # data ygrid = ygrid - 2048 n_psfs = grid_data['n_psfs'] data = grid_data['data'] data_ny, data_nx = data.shape[1:] data = data.reshape((2, n_psfs // 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) nx_det = 2 ny_det = 2 det_size = 800 # Map of detector ID to index in the 2x2 grid of detectors. The # detector IDs are defined in the STDPSF filenames as follows: # 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 = {'nx_det': nx_det, 'ny_det': ny_det, '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) nx_det = 4 ny_det = 2 det_size = 2048 # Map of detector ID to index in the 4x2 grid of detectors. The # detector IDs are defined in the STDPSF filenames as follows: # 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 = {'nx_det': nx_det, 'ny_det': ny_det, '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, path-like, file-like, or `~astropy.io.fits.HDUList` The name of the STDPSF FITS file, an open file object, or an already-open `~astropy.io.fits.HDUList`. detector_id : int or `None` The detector ID. It selects the detector name for a file that contains ePSF grids for multiple detectors. Returns ------- meta : dict or `None` A dictionary containing the metadata. `None` is returned if a filename string cannot be determined from the input or if the filename does not have the STDPSF form. """ if isinstance(filename, fits.HDUList): filename = filename.filename() elif isinstance(filename, io.IOBase): filename = getattr(filename, 'name', None) elif isinstance(filename, os.PathLike): filename = os.fspath(filename) if not isinstance(filename, str): return None # Strip the file extension (e.g., '.fits' or '.fits.gz') before # splitting the filename into its underscore-separated fields. basename = os.path.basename(filename).split('.')[0] parts = basename.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} try: # Copy so that the module-level map is never mutated inst_det = list(_STDPSF_DETECTOR_MAP[detector]) except KeyError as exc: if detector_id is None: return meta msg = f'Unknown detector {detector}' raise ValueError(msg) from exc # The detector name within a multidetector file depends on # detector_id if detector_id is not None: 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, path-like, file-like, or `~astropy.io.fits.HDUList` The name of the STDPSF FITS file, an open file object, or an already-open `~astropy.io.fits.HDUList`. A URL can also be used. A filename, or the ``name`` of a file object, must end with a FITS extension (e.g., ``.fits`` or ``.fits.gz``). Otherwise a `TypeError` is raised. 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, chip 1 (WFC1, UVIS1) is above chip 2 (WFC2, UVIS2) in the y direction. In the FLT FITS files, the (sci, 1) extension corresponds to chip 2 and the (sci, 2) extension corresponds to chip 1. 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) # Number of ePSFs in the STDPSF files that contain grids for # multiple detectors, mapped to the function that extracts a single # detector from the grid splitters = {90: _split_wfc_uvis, # ACS/WFC or WFC3/UVIS (2 chips) 56: _split_wfc_uvis, # ACS/WFC or WFC3/UVIS (2 chips) 36: _split_wfpc2, # WFPC2 (4 chips) 200: _split_nrcsw} # NIRCam SW (8 chips) splitter = splitters.get(grid_data['n_psfs']) if splitter is not None: data, xgrid, ygrid = splitter(grid_data, detector_id) else: data = grid_data['data'] xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] # itertools.product iterates over the last input first xy_grid = np.array([yx[::-1] for yx in itertools.product(ygrid, xgrid)]) oversampling = 4 # assumption for STDPSF files meta = {'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, 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 contains a 3D array of ePSFs with the header detailing where the fiducial ePSFs are located in the detector coordinate frame. Parameters ---------- filename : str, path-like, or file-like The name of the WebbPSF FITS file or an open file object. A URL can also be used. Returns ------- model : `~photutils.psf.GriddedPSFModel` The gridded ePSF model. Notes ----- The STPSF (formerly WebbPSF) ``psf_grid`` method makes these ePSFs from PSFs that are sampled at the points of the oversampled grid. It integrates each PSF over the detector pixels with a discrete box kernel, which is a low-order approximation of the integral. The ePSF peak and the fluxes fitted with these models can therefore be in error by up to a few percent for the filters with the narrowest PSFs. A more accurate ePSF can be made by applying `~photutils.psf.make_epsf_from_psf` to the oversampled PSF that the STPSF ``calc_psf`` method returns. See :ref:`psf-image-models` for the measured errors and an example. """ 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 if data.ndim == 2: data = data[np.newaxis, :] 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} # use lowercase keys # Define grid_xypos from the DET_YX{i} FITS header keywords. The # keywords are sorted by their numeric index so that the positions # always match the order of the ePSF planes in the data array. The # header values are the '(y, x)' detector positions, but grid_xypos # is defined in (x, y) order. det_yx_keys = {} for key in header: match = _DET_YX_PATTERN.match(key) if match is not None: det_yx_keys[int(match.group(1))] = key xypos = [] for index in sorted(det_yx_keys): vals = header[det_yx_keys[index]].lstrip('(').rstrip(')').split(',') xypos.append((float(vals[1]), float(vals[0]))) meta['grid_xypos'] = xypos if 'oversampling' not in meta: meta['oversampling'] = meta['oversamp'] ndd = NDData(data, meta=meta) return GriddedPSFModel(ndd) def _has_fits_header_keys(filepath, keys, *, fileobj=None): """ Determine whether a file is a FITS file whose primary header contains all of the given keywords. Parameters ---------- filepath : str, path-like, or `None` The file path of the FITS file. keys : tuple of str The FITS header keywords that must all be present. fileobj : file-like object or `None`, optional An open file object to read the file's contents. If given, the header is read from it instead of reopening ``filepath``. Returns ------- result : bool Returns `True` if the file is a FITS file containing all of the input keywords. """ if fileobj is not None: try: fileobj.seek(0) with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) header = fits.getheader(fileobj) except OSError: # A corrupt file is not a FITS file return False return all(key in header for key in keys) if isinstance(filepath, os.PathLike): filepath = os.fspath(filepath) if filepath is None or not filepath.lower().endswith(_FITS_EXTENSIONS): return False try: with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) header = fits.getheader(filepath) except OSError: # A corrupt file with a FITS extension is not a FITS file return False return all(key in header for key in keys) 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 or path-like 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. """ return _has_fits_header_keys(filepath, ('NAXIS3', 'NXPSFS', 'NYPSFS'), fileobj=fileobj) 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 or path-like 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. """ return _has_fits_header_keys(filepath, ('NAXIS3', 'OVERSAMP', 'DET_YX0'), fileobj=fileobj) astropy-photutils-24be529/photutils/psf/model_plotting.py000066400000000000000000000234331526452531300240320ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for plotting gridded PSF models. """ import numpy as np __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`, a new figure and axes are created. vmax_scale : float or `None`, optional Scale factor to apply to the image stretch limits. This value is multiplied by the maximum of the plotted data (the ePSF data, or the ePSF differences if ``deltas=True``) to determine the plotting ``vmax``. If `None`, the value is 1.0 for the ePSF data and 0.03 for the ePSF differences. If ``deltas=True``, ``vmin`` is set to ``-vmax``. Otherwise ``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. If ``deltas=True``, the differences are divided by the peak of the average ePSF. 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 ``dividers=True``. figsize : (float, float), optional The figure (width, height) in inches. It is used only if ``ax`` is `None`. Returns ------- fig : `matplotlib.figure.Figure` The matplotlib figure object. This is a new figure if ``ax=None``, otherwise the figure of ``ax``. Use ``fig.savefig()`` to save the figure to a file. Raises ------ ValueError If ``deltas=True`` and all ePSFs in the grid are blank (all pixels are zero). 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. """ ny_grid = self.model._ygrid.shape[0] nx_grid = self.model._xgrid.shape[0] ny, nx = self.model.data.shape[1:] return (data.reshape(ny_grid, nx_grid, ny, nx) .transpose([0, 2, 1, 3]) .reshape(ny_grid * ny, nx_grid * 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 astropy.visualization import simple_norm from mpl_toolkits.axes_grid1 import make_axes_locatable data = self.model.data.astype(float) if deltas: # Compute mean ignoring any blank (all zeros) ePSFs. # This is the case for MIRI with its nonsquare FOV. mask = np.zeros(data.shape[0], dtype=bool) for i, arr in enumerate(data): if np.count_nonzero(arr) == 0: mask[i] = True if mask.all(): msg = ('deltas cannot be computed because all ePSFs ' 'in the grid are blank (all pixels are zero)') raise ValueError(msg) mean_epsf = np.mean(data[~mask], axis=0) data -= mean_epsf data[mask] = 0.0 # For peak normalization, use the peak of the mean ePSF so # that the differences are relative to the average ePSF # peak peak = mean_epsf.max() else: peak = data.max() 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 peak != 0: # Normalize relative to peak data /= peak 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 ny_grid = self.model._ygrid.shape[0] nx_grid = self.model._xgrid.shape[0] extent = [-0.5, nx_grid - 0.5, -0.5, ny_grid - 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(nx_grid)) ax.set_xticklabels(xticklabels) ax.set_xlabel('ePSF location in detector X pixels') ax.set_yticks(np.arange(ny_grid)) ax.set_yticklabels(yticklabels) ax.set_ylabel('ePSF location in detector Y pixels') if dividers: for ix in range(nx_grid - 1): ax.axvline(ix + 0.5, color=divider_color, ls=divider_ls) for iy in range(ny_grid - 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] # Join only the metadata values that are present so that # missing values do not leave stray spaces in the title title = ' '.join(str(value) for value in (instrument, detector, filtername) if value) 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. nx_grid = len(self.model._xgrid) ny_grid = len(self.model._ygrid) ax.axhline(ny_grid / 2 - 0.5, color='orange') for i in range(1, 4): ax.axvline(nx_grid / 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 * nx_grid / 4 - 0.45, (i + 1) * ny_grid / 2 - 0.55, det_labels[i][j], color='orange', verticalalignment='top', fontsize=12) fig.tight_layout() return fig astropy-photutils-24be529/photutils/psf/photometry.py000066400000000000000000002460051526452531300232260ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for performing PSF-fitting photometry. """ import inspect import sys import warnings from concurrent.futures import ThreadPoolExecutor, as_completed from copy import deepcopy from dataclasses import dataclass, field from itertools import pairwise from queue import SimpleQueue from typing import NamedTuple 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning __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, as 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 model parameters that are free (not fixed). """ # 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 def _gil_enabled(): """ Return whether the Python global interpreter lock (GIL) is enabled. Returns ------- result : bool `False` only on a free-threaded Python build running with the GIL disabled. """ return getattr(sys, '_is_gil_enabled', lambda: True)() class _GroupFitResult(NamedTuple): """ The fit results of one group of sources (see `_FitEngine.fit_group`). The per-source arrays and lists are in the order of the group rows. """ row_indices: np.ndarray group_size: int n_pixels_fit: np.ndarray invalid_reasons: list valid_mask: np.ndarray param_values: dict param_fixed: dict param_bounds: dict fit_param_errs: np.ndarray fit_info: list sum_abs_residuals: np.ndarray cen_residuals: np.ndarray reduced_chi2: np.ndarray class _FitEngine: """ Fit groups of sources independently of a `PSFPhotometry` instance. The engine holds the image arrays and the fitting components and returns the results of each group as a `_GroupFitResult` without touching any `PSFPhotometry` state, so groups can be fitted in any order. An engine must not be used by two threads at once because the fitter keeps the information of its last fit. With ``n_threads`` > 1, `PSFPhotometry` gives every thread its own engine from `thread_copy`. Parameters ---------- psf_model : `~astropy.modeling.Model` The PSF model. param_mapper : `_PSFParameterMapper` The parameter mapper of the PSF model. data_processor : `~photutils.psf._components.PSFDataProcessor` The cutout extractor. psf_fitter : `~photutils.psf._components.PSFFitter` The group model builder and fitter. data, mask, error : `~numpy.ndarray` or `None` The image arrays, with any units already stripped. """ def __init__(self, *, psf_model, param_mapper, data_processor, psf_fitter, data, mask, error): self.psf_model = psf_model self.param_mapper = param_mapper self.data_processor = data_processor self.psf_fitter = psf_fitter self.data = data self.mask = mask self.error = error self.y_offsets, self.x_offsets = data_processor.get_fit_offsets() self.n_fit_params = len(param_mapper.fitted_param_names) def thread_copy(self): """ Return an engine that can fit groups in another thread. Returns ------- engine : `_FitEngine` An engine with its own copies of the PSF model and the fitter, which hold state that is modified during a fit. The image arrays are only read and are shared. """ psf_model = self.psf_model.copy() param_mapper = _PSFParameterMapper(psf_model) data_processor = PSFDataProcessor(param_mapper, self.data_processor.fit_shape) psf_fitter = PSFFitter( psf_model, param_mapper, fitter=deepcopy(self.psf_fitter.fitter), fitter_maxiters=self.psf_fitter.fitter_maxiters, xy_bounds=self.psf_fitter.xy_bounds) return _FitEngine(psf_model=psf_model, param_mapper=param_mapper, data_processor=data_processor, psf_fitter=psf_fitter, data=self.data, mask=self.mask, error=self.error) def fit_groups(self, sources): """ Fit the source groups of a table. Parameters ---------- sources : `~astropy.table.Table` The initial parameters of the sources, which must hold only complete groups. Returns ------- results : list of `_GroupFitResult` One result per group. """ return [self.fit_group(group) for group in sources.group_by('group_id').groups] def _fitter_residuals(self): """ Return the residual vector of the last fit, or `None` if the fitter does not provide one. """ fit_info = getattr(self.psf_fitter.fitter, 'fit_info', None) if not isinstance(fit_info, dict): return None for key in ('fvec', 'fun'): # fvec is the LevMarLSQFitter key if key in fit_info: return fit_info[key] return None def _residual_metrics(self, residuals, valid_mask, n_pixels_fit, cen_index, xi_all, yi_all): """ Calculate the residual-based fit metrics of the valid sources of a group. Parameters ---------- residuals : `~numpy.ndarray` or `None` The concatenated (weighted) residuals of the group fit. valid_mask : `~numpy.ndarray` Which sources of the group were fitted. n_pixels_fit, cen_index : `~numpy.ndarray` The number of fitted pixels and the index of the center pixel of each source of the group. xi_all, yi_all : list of `~numpy.ndarray` The pixel coordinates of each valid source's cutout. Returns ------- sum_abs_residuals, cen_residuals, reduced_chi2 : `~numpy.ndarray` The metrics, NaN for invalid sources or where undefined. """ n_sources = len(valid_mask) sum_abs_residuals = np.full(n_sources, np.nan) cen_residuals = np.full(n_sources, np.nan) reduced_chi2 = np.full(n_sources, np.nan) if residuals is None: return sum_abs_residuals, cen_residuals, reduced_chi2 valid_indices = np.flatnonzero(valid_mask) n_pixels_valid = n_pixels_fit[valid_indices] starts = np.concatenate(([0], np.cumsum(n_pixels_valid))) for idx, valid_idx in enumerate(valid_indices): source_residuals = residuals[starts[idx]:starts[idx + 1]] # The residuals are (model - data) / error when errors are # input. qfit and cfit need the raw residuals, so multiply # by the errors, which run_fitter has validated as positive # and finite. raw_residuals = source_residuals error_vals = None if self.error is not None: error_vals = self.error[yi_all[idx], xi_all[idx]] raw_residuals = source_residuals * error_vals sum_abs_residuals[valid_idx] = float(np.abs(raw_residuals).sum()) cen_idx = cen_index[valid_idx] if np.isfinite(cen_idx): # cfit uses (data - model) cen_residuals[valid_idx] = float(-raw_residuals[int(cen_idx)]) # The reduced chi-squared needs errors and degrees of # freedom dof = float(n_pixels_valid[idx] - self.n_fit_params) if error_vals is not None and dof > 0: reduced_chi2[valid_idx] = np.sum(source_residuals**2) / dof return sum_abs_residuals, cen_residuals, reduced_chi2 def fit_group(self, source_group): """ Fit one group of sources. Parameters ---------- source_group : `~astropy.table.Table` The initial parameters of the sources of the group, including the ``_row_index`` column. Returns ------- result : `_GroupFitResult` The per-source results, in the order of the group rows. Invalid sources have NaN parameters and metrics, the parameter ``fixed`` and ``bounds`` settings of the PSF model, and an empty ``fit_info``. """ data = self.data group_size = len(source_group) xi_all = [] yi_all = [] cutout_all = [] n_pixels_fit = [] cen_index = [] valid_list = [] invalid_reasons = [] row_indices = [] for row in source_group: should_skip, reason = self.data_processor.should_skip_source( row, data.shape) if should_skip: res = {'valid': False, 'reason': reason, 'xx': None, 'yy': None, 'cutout': None, 'n_pixels': 0, 'cen_index': np.nan} else: res = self.data_processor.get_source_cutout_data( row, data, self.mask, self.y_offsets, self.x_offsets) n_pixels_fit.append(res['n_pixels']) cen_index.append(res['cen_index']) invalid_reasons.append(res['reason'] or '') row_indices.append(row['_row_index']) if res['valid'] and res['n_pixels'] >= self.n_fit_params: valid_list.append(True) xi_all.append(res['xx']) yi_all.append(res['yy']) cutout_all.append(res['cutout']) else: if res['valid']: invalid_reasons[-1] = 'too_few_pixels' valid_list.append(False) row_indices = np.array(row_indices, dtype=int) valid_mask = np.array(valid_list, dtype=bool) n_pixels_fit = np.array(n_pixels_fit, dtype=int) cen_index = np.array(cen_index, dtype=float) n_valid = int(np.count_nonzero(valid_mask)) # The defaults of invalid sources come from the PSF model param_values = {} param_fixed = {} param_bounds = {} for name in self.psf_model.param_names: param = getattr(self.psf_model, name) param_values[name] = np.full(group_size, np.nan) param_fixed[name] = [param.fixed] * group_size param_bounds[name] = [param.bounds] * group_size fit_param_errs = np.full((group_size, self.n_fit_params), np.nan) fit_info = [{} for _ in range(group_size)] residuals = None if n_valid > 0: valid_sources = source_group[valid_mask] group_model = self.psf_fitter.make_psf_model(valid_sources) fit_model, group_fit_info = self.psf_fitter.run_fitter( group_model, np.concatenate(xi_all), np.concatenate(yi_all), np.concatenate(cutout_all), self.error) residuals = self._fitter_residuals() # Split the group model and covariance into per-source parts param_cov = group_fit_info.get('param_cov') if param_cov is None: source_errs = np.full((n_valid, self.n_fit_params), np.nan) source_covs = [None] * n_valid else: # The flat model parameters are ordered by source, # with all the parameters of the first source followed # by those of the second source and so on source_errs = np.sqrt(np.diag(param_cov)).reshape( n_valid, self.n_fit_params) source_covs = self.psf_fitter.extract_source_covariances( param_cov, n_valid, self.n_fit_params) if n_valid == 1: source_models = [fit_model] else: source_models = self.psf_fitter.split_flat_model(fit_model, n_valid) for valid_idx, i in enumerate(np.flatnonzero(valid_mask)): model = source_models[valid_idx] for name in model.param_names: param = getattr(model, name) param_values[name][i] = param.value param_fixed[name][i] = param.fixed param_bounds[name][i] = param.bounds fit_param_errs[i] = source_errs[valid_idx] source_fit_info = dict(group_fit_info) if source_covs[valid_idx] is not None: source_fit_info['param_cov'] = source_covs[valid_idx] fit_info[i] = source_fit_info sum_abs_residuals, cen_residuals, reduced_chi2 = ( self._residual_metrics(residuals, valid_mask, n_pixels_fit, cen_index, xi_all, yi_all)) return _GroupFitResult( row_indices=row_indices, group_size=group_size, n_pixels_fit=n_pixels_fit, invalid_reasons=invalid_reasons, valid_mask=valid_mask, param_values=param_values, param_fixed=param_fixed, param_bounds=param_bounds, fit_param_errs=fit_param_errs, fit_info=fit_info, sum_abs_residuals=sum_abs_residuals, cen_residuals=cen_residuals, reduced_chi2=reduced_chi2) 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. The model is evaluated at the center of each pixel, and the result is compared with the pixel value. The value of the model at a position must therefore be the flux in a pixel centered at that position, as for the analytic models whose names end in ``PRF`` and for an image-based model made from an ePSF (see :ref:`psf-terminology`). A model that is not integrated over the pixels gives biased fluxes and positions for undersampled data. The analytic models whose names end in ``PSF`` (e.g., `~photutils.psf.CircularGaussianPSF` and `~photutils.psf.MoffatPSF`) are not integrated over the pixels and should not be used here. Each has a counterpart whose name ends in ``PRF`` (e.g., `~photutils.psf.CircularGaussianPRF` and `~photutils.psf.MoffatPRF`). With a ``PSF`` model whose shape parameters are fixed, the fitted flux is biased by a few percent even for a FWHM of 2 to 3 pixels. The fitted source positions are the fitted values of the model position parameters. For an image-based model, they give the position of the center of the model image, or of the ``origin`` of an `ImagePSF`. The PSF must therefore be centered on that point of the model image. Otherwise, the fitted positions are all offset by the distance of the PSF center from that point. 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 a positive odd number. In general, ``fit_shape`` should be set to a small size (e.g., ``(5, 5)``) that covers the region with the highest flux signal-to-noise. finder : callable or `~photutils.detection.StarFinderBase` or `None`, \ optional A callable used to identify sources in an image. The ``finder`` must accept a 2D image as its first argument and a ``mask`` keyword, and return a `~astropy.table.Table` containing the x and y centroid positions (or `None` if no sources are found). 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` or `None`, 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. In the latter case, a warning is issued when the class is created. 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. n_threads : int, optional The number of threads used to fit the sources. The default is 1 (no multithreading). When ``n_threads`` > 1, the source groups are divided into chunks that are fitted concurrently. Each group is fitted independently, so the results are identical to the single-threaded computation. Every thread fits with its own copy of the PSF model and the ``fitter``, so the input ``fitter`` is not called and its ``fit_info`` is not updated. The fitting runs mostly in Python code that holds the global interpreter lock (GIL), so multithreading speeds up the fitting only on a free-threaded Python build. When the GIL is enabled, a warning is issued and the sources are fitted in a single thread. 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. Attributes ---------- results : `~astropy.table.QTable` or `None` The table of fit results from the most recent call, or `None` if the instance has not yet been run. The rows are sorted by the source ``id`` column. fit_info : list of dict A list of dictionaries, one per source in the same row order as ``results``, containing information about each source fit with keys such as ``param_cov``, ``ierr``, ``message``, and ``status`` (the exact keys depend on the ``fitter``). finder_results : `~astropy.table.Table` or `None` The table of sources returned by the ``finder`` during the most recent call, or `None` if the finder was not used. init_params : `~astropy.table.Table` or `None` The table of initial parameters used in the most recent call, or `None` if the instance has not yet been run. data_unit : `~astropy.units.Unit` or `None` The unit of the input data from the most recent call, or `None` if the data did not have units. 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 latter 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, because the number of fitted parameters grows with the group size. A warning will be raised if the number of sources in a group exceeds the ``group_warning_threshold`` value. This class stores per-call state on the instance (e.g., ``results`` and ``fit_info``), so a single instance must not be called concurrently from multiple threads. Create one instance per thread for concurrent use. Sharing a single Astropy fitter instance across concurrently-used objects is also unsafe because Astropy fitters store ``fit_info`` on themselves. """ # Default value for parameter initialization (invalid sources) _DEFAULT_PARAM_VALUE = np.nan # Results columns ending in '_fit' that are not fitted model # parameters _NON_PARAM_FIT_COLS = ('n_pixels_fit',) @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, n_threads=1, 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 = self._validate_group_threshold( group_warning_threshold) if (isinstance(n_threads, bool) or not isinstance(n_threads, (int, np.integer)) or n_threads < 1): msg = 'n_threads must be a positive integer' raise ValueError(msg) self.n_threads = int(n_threads) 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, ) self._results_assembler = PSFResultsAssembler(self._param_mapper, self.fit_shape) # Callers that report nonconvergence themselves set this to # False self._warn_no_convergence = True # 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', 'n_threads', '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 state with components self._data_processor.data_unit = None self._data_processor.finder_results = 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. """ n_fit_params = len(self._param_mapper.fitted_param_names) self._state.update({ 'fit_param_errs': np.full((n_sources, n_fit_params), 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 _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 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 Expected type for the object. Returns ------- obj : object or None The validated object. Raises ------ TypeError If obj is not None and not an instance of expected_type. """ if obj is not None and not isinstance(obj, expected_type): if not isinstance(expected_type, tuple): expected_type = (expected_type,) type_name = ' or '.join(t.__name__ for t in expected_type) 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 (nonpositive or nonfinite). """ 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) # None elements are allowed to indicate no bound along an axis if not all(bound is None or isinstance(bound, (int, float, np.number)) for bound in xy_bounds): msg = 'xy_bounds must be numeric' 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: if (isinstance(radius, bool) or np.ndim(radius) != 0 or radius <= 0 or not np.isfinite(radius)): msg = 'aperture_radius must be a strictly-positive scalar' raise ValueError(msg) radius = float(radius) return radius @staticmethod def _validate_group_threshold(value): """ Validate the input ``group_warning_threshold`` value. Parameters ---------- value : int The group size threshold to validate. Returns ------- value : int The validated threshold value. Raises ------ ValueError If value is not a positive integer. """ if (isinstance(value, bool) or not isinstance(value, (int, np.integer)) or value <= 0): msg = 'group_warning_threshold must be a positive integer' raise ValueError(msg) return int(value) 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 ``fitter_maxiters`` value. Parameters ---------- maxiters : int 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. Raises ------ ValueError If maxiters is not a strictly-positive integer. """ if (isinstance(maxiters, bool) or not isinstance(maxiters, (int, np.integer)) or maxiters <= 0): msg = 'fitter_maxiters must be a strictly-positive integer' raise ValueError(msg) maxiters = int(maxiters) spec = inspect.signature(self.fitter.__call__) if 'maxiter' not in spec.parameters: msg = ("'fitter_maxiters' will be ignored because the " "fitter's __call__ method does not accept a " "'maxiter' keyword.") 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. """ 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. """ # A user-provided group_id column takes precedence for this # call derived_from_id = False if 'group_id' not in init_params.colnames: if self.grouper is not None: 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() derived_from_id = True # 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): if derived_from_id: msg = ('group_id (or the id column it was derived from) ' 'must be an integer array') else: 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 _, 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 boolean mask combining the input mask and any nonfinite data values, or `None` if no pixels are masked. 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. In that case, the other three return values are also `None`. """ (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) if init_params is not None and len(init_params) == 0: msg = 'init_params must contain at least one row' raise ValueError(msg) init_params = self._build_initial_parameters(data, mask, init_params) if init_params is None: # No sources found return None, None, None, None ids = np.asarray(init_params['id']) if len(np.unique(ids)) != len(ids): msg = 'init_params id column must contain unique values' raise ValueError(msg) return data, mask, error, init_params def _fit_source_groups(self, init_params, data, mask, error): """ Fit the source groups and store the per-source results. The groups are fitted by a `_FitEngine`, or by one engine per thread when ``n_threads`` > 1 and the GIL is disabled, and each group's results are scattered into the state container by `_store_group_result`. Parameters ---------- init_params : `~astropy.table.Table` The initial parameters of the sources, including the ``group_id`` and ``_row_index`` columns. 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. """ engine = _FitEngine(psf_model=self.psf_model, param_mapper=self._param_mapper, data_processor=self._data_processor, psf_fitter=self._psf_fitter, data=data, mask=mask, error=error) grouped = init_params.group_by('group_id') n_threads = min(self.n_threads, len(grouped.groups)) # Threads holding the GIL would be slower than a single thread if n_threads > 1 and _gil_enabled(): msg = ('n_threads > 1 speeds up the PSF fitting only on a ' 'free-threaded Python build. The GIL is enabled, so ' 'the sources are fitted in a single thread.') warnings.warn(msg, AstropyUserWarning) n_threads = 1 if n_threads == 1: groups = grouped.groups if self.progress_bar: groups = add_progress_bar(groups, desc='Fit source/group') for group in groups: self._store_group_result(engine.fit_group(group)) return # Every chunk borrows one of n_threads engines, so no engine # is ever used by two threads at once. engines = SimpleQueue() for _ in range(n_threads): engines.put(engine.thread_copy()) def fit_chunk(chunk): thread_engine = engines.get() try: return thread_engine.fit_groups(chunk) finally: engines.put(thread_engine) # Several chunks per thread balance the uneven group costs. # The warning filter set by the fitting code in each thread is # process-wide on some Python builds, where concurrent threads # can restore the filters in the wrong order. Setting the same # filter here keeps it in place while the threads run and # restores the caller's filters afterward. chunks = self._chunk_groups(grouped, 4 * n_threads) with (warnings.catch_warnings(), ThreadPoolExecutor(max_workers=n_threads) as executor): warnings.simplefilter('ignore', AstropyUserWarning) futures = [executor.submit(fit_chunk, chunk) for chunk in chunks] completed = as_completed(futures) if self.progress_bar: completed = add_progress_bar(completed, desc='Fit source chunk', total=len(futures)) try: for future in completed: for result in future.result(): self._store_group_result(result) except BaseException: # Do not fit the remaining chunks after a failure executor.shutdown(cancel_futures=True) raise @staticmethod def _chunk_groups(grouped, n_chunks): """ Split a grouped table into chunks of whole groups with roughly equal numbers of sources. Parameters ---------- grouped : `~astropy.table.Table` The sources grouped by ``group_id``. n_chunks : int The requested number of chunks. Fewer are returned when there are fewer groups. Returns ------- chunks : list of `~astropy.table.Table` The chunks, each holding one or more complete groups. """ indices = grouped.groups.indices n_sources = len(grouped) n_chunks = min(n_chunks, len(indices) - 1) bounds = [0] for k in range(1, n_chunks): # The first group boundary at or after the ideal cut cut = int(indices[np.searchsorted(indices, k * n_sources / n_chunks)]) if bounds[-1] < cut < n_sources: bounds.append(cut) bounds.append(n_sources) return [grouped[start:end] for start, end in pairwise(bounds)] def _store_group_result(self, result): """ Scatter the results of one group into the state container. Parameters ---------- result : `_GroupFitResult` The group results from `_FitEngine.fit_group`. """ rows = result.row_indices state = self._state state['group_size'][rows] = result.group_size state['n_pixels_fit'][rows] = result.n_pixels_fit state['valid_mask_by_id'][rows] = result.valid_mask state['fit_param_errs'][rows] = result.fit_param_errs state['sum_abs_residuals'][rows] = result.sum_abs_residuals state['cen_residuals'][rows] = result.cen_residuals state['reduced_chi2'][rows] = result.reduced_chi2 param_data = state['model_param_data'] for name in self.psf_model.param_names: param_data[name][rows] = result.param_values[name] for i, row in enumerate(rows): state['invalid_reasons'][row] = result.invalid_reasons[i] self.fit_info[row] = result.fit_info[i] for name in self.psf_model.param_names: param_data[f'{name}_fixed'][row] = result.param_fixed[name][i] param_data[f'{name}_bounds'][row] = ( result.param_bounds[name][i]) 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 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 their errors 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)) try: self._fit_source_groups(init_params, data, mask, error) finally: # 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, warn_no_convergence=self._warn_no_convergence) @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 @staticmethod def _check_nddata_kwargs(*, mask, error): """ Reject explicit mask/error keywords for NDData inputs. Parameters ---------- mask : 2D `~numpy.ndarray` or `None` The mask keyword value passed to ``__call__``. error : 2D `~numpy.ndarray` or `None` The error keyword value passed to ``__call__``. Raises ------ ValueError If mask or error is not None. """ if mask is not None or error is not None: msg = ('The mask and error keywords must be None when ' 'data is an NDData instance. Define the mask and ' 'uncertainty in the NDData object instead.') raise ValueError(msg) @_create_call_docstring(iterative=False) def __call__(self, data, *, mask=None, error=None, init_params=None): # Reset state from previous runs self._reset_results() if not self._param_mapper.fitted_param_names: msg = ('psf_model has no free parameters to fit. At least one ' 'model parameter must not be fixed.') raise ValueError(msg) try: # Handle NDData input if isinstance(data, NDData): self._check_nddata_kwargs(mask=mask, error=error) 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) # Reorder fit_info to match the results table rows, which # are sorted by the id column during table assembly row_map = {src_id: idx for idx, src_id in enumerate(init_params['id'])} self.fit_info = [self.fit_info[row_map[src_id]] for src_id in self.results['id']] 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', warning_type=PhotutilsDeprecationWarning) 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 (col_name.endswith('_fit') and col_name not in self._NON_PARAM_FIT_COLS) or col_name.endswith('_err')): tbl[col_name] = self.results[col_name] return tbl @staticmethod def _iter_fit_param_cols(results_tbl): """ Yield the 'id' column name plus the fitted model-parameter column names. """ for col_name in results_tbl.colnames: if col_name == 'id' or ( col_name.endswith('_fit') and col_name not in PSFPhotometry._NON_PARAM_FIT_COLS): yield col_name @staticmethod def _finite_fit_mask(tbl): """ Return a boolean mask of rows where every column is finite. """ return np.all([np.isfinite(tbl[col]) for col in tbl.colnames], axis=0) @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 nonfinite 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 PSFPhotometry._iter_fit_param_cols(results_tbl): init_name = col_name.replace('_fit', '_init') tbl[init_name] = results_tbl[col_name] if remove_invalid: # Remove rows with any nonfinite fitted values keep = PSFPhotometry._finite_fit_mask(tbl) 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 nonfinite 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 PSFPhotometry._iter_fit_param_cols(results_tbl): 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 nonfinite fitted values keep = PSFPhotometry._finite_fit_mask(tbl) 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 initial parameters from the fitted 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 nonfinite 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`. Returns ------- init_params_tbl : `~astropy.table.QTable` or `None` The table of initial parameters, or `None` if the instance has not yet been run. """ 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 nonfinite 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`. Returns ------- model_params_tbl : `~astropy.table.QTable` or `None` The table of model parameters, or `None` if the instance has not yet been run. """ 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 names (strings). Default is `False`. Returns ------- decoded : dict A dictionary mapping each source id from the results table to the list of its active flag names (or bit values). If no flags are set for a source, its value is an empty list. The entries follow the results-table order. .. versionchanged:: 3.1 The result is now a dictionary keyed by source id. Previously, a positional list of lists was returned. 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) >>> for source_id, flags in photometry.decode_flags().items(): ... print(f'Source {source_id}: {flags}') # doctest: +SKIP Source 1: [] Source 2: ['non_positive_flux'] """ if self.results is None: msg = ('No results available. Please run the PSFPhotometry ' 'instance first.') raise ValueError(msg) decoded = decode_psf_flags(self.results['flags'], return_bit_values=return_bit_values) return {int(id_): flags for id_, flags in zip(self.results['id'], decoded, strict=True)} 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-24be529/photutils/psf/simulation.py000066400000000000000000000200531526452531300231710ustar00rootroot00000000000000# 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. The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. The model is evaluated at the center of each pixel. To simulate the pixelated image of a source, the value of the model at a position must be the flux in a pixel centered at that position, as for the analytic models whose names end in ``PRF`` and for an image-based model made from an ePSF (see :ref:`psf-terminology`). 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`, int, or 2-tuple of int, optional The shape around the center (x, y) position that will be used to evaluate the ``psf_model``. If a single integer is provided, a square shape is used. 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 : dict 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 sampled over the half-open interval defined by the lower and upper bounds (i.e., the lower bound is inclusive and the upper bound is exclusive). A ``flux`` keyword is mapped to the model's flux parameter name (e.g., for models with a ``flux_name`` attribute). If the parameter is not in the input ``psf_model`` parameter names, it will be ignored. Keywords matching the model's x and y position parameter names are also ignored because the source positions are randomly generated. Returns ------- data : 2D `~numpy.ndarray` The simulated image. table : `~astropy.table.QTable` A table containing the parameters of the generated sources. It has an ``'id'`` column with unique source IDs, columns for the x and y positions named after the ``psf_model`` position parameters, and one column for each model parameter given in ``**kwargs``, named after the model parameter. A parameter that is not given, including the flux, has no column and takes the value set in ``psf_model`` for every source. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If fewer than ``n_sources`` sources could be generated with the given ``shape`` and ``min_separation``. 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) 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') """ 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 model parameters, but not the # x and y position parameters for key, val in kwargs.items(): param = key if key == 'flux' and 'flux' not in psf_model.param_names: # Map the flux kwarg to the model's flux parameter name param = main_params[2] if (param not in psf_model.param_names or param in main_params[0:2]): continue # skip the x, y parameters other_params[param] = 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-24be529/photutils/psf/tests/000077500000000000000000000000001526452531300215755ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf/tests/__init__.py000066400000000000000000000000001526452531300236740ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf/tests/conftest.py000066400000000000000000000072461526452531300240050ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Common test fixtures for the psf module tests. """ import sys import threading import weakref from itertools import product import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.nddata import NDData from scipy.interpolate import RectBivariateSpline from photutils.psf import GriddedPSFModel @pytest.fixture(name='short_switch_interval') def fixture_short_switch_interval(): """ Fixture that makes the interpreter switch between threads very often, so that a race between threads shows up on builds with the GIL instead of only on free-threaded builds. """ interval = sys.getswitchinterval() sys.setswitchinterval(1e-6) yield sys.setswitchinterval(interval) @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 SplineBuilds: """ A record of the spline objects that the PSF image models build. Attributes ---------- count : int The number of splines built. A test can reset it to zero. """ def __init__(self): self.count = 0 self._refs = [] self._lock = threading.Lock() def record(self, spline): """ Record a new spline object. """ with self._lock: self.count += 1 self._refs.append(weakref.ref(spline)) @property def n_alive(self): """ The number of recorded spline objects that still exist. """ return sum(ref() is not None for ref in self._refs) @pytest.fixture(name='spline_builds') def fixture_spline_builds(monkeypatch): """ Fixture that records the `~scipy.interpolate.RectBivariateSpline` objects built by the `ImagePSF` and `GriddedPSFModel` models. """ builds = SplineBuilds() class CountedSpline(RectBivariateSpline): def __init__(self, *args, **kwargs): builds.record(self) super().__init__(*args, **kwargs) for module in ('image_models', 'gridded_models'): monkeypatch.setattr(f'photutils.psf.{module}.RectBivariateSpline', CountedSpline) return builds @pytest.fixture(name='public_spline') def fixture_public_spline(): """ Fixture that returns a stand-in for `~scipy.interpolate.RectBivariateSpline` that has only its public interface, without the undocumented ``tck`` and ``degrees`` attributes. """ class PublicSpline: def __init__(self, *args, **kwargs): self._spline = RectBivariateSpline(*args, **kwargs) def __call__(self, *args, **kwargs): return self._spline(*args, **kwargs) def get_knots(self): return self._spline.get_knots() def get_coeffs(self): return self._spline.get_coeffs() def partial_derivative(self, dx, dy): return self._spline.partial_derivative(dx, dy) return PublicSpline @pytest.fixture(name='gil_disabled') def fixture_gil_disabled(monkeypatch): """ Make the PSF photometry classes see a free-threaded Python build, so that ``n_threads`` > 1 fits in threads on any build. """ monkeypatch.setattr('photutils.psf.photometry._gil_enabled', lambda: False) astropy-photutils-24be529/photutils/psf/tests/data/000077500000000000000000000000001526452531300225065ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf/tests/data/STDPSF_ACSWFC_F814W_mock.fits000066400000000000000000000341001526452531300272660ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/psf/tests/data/STDPSF_NRCA1_F150W_mock.fits000066400000000000000000000132001526452531300271130ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/psf/tests/data/STDPSF_NRCSW_F150W_mock.fits000066400000000000000000000550001526452531300272070ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/psf/tests/data/STDPSF_WFC3UV_F814W_mock.fits000066400000000000000000000207001526452531300272760ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/psf/tests/data/STDPSF_WFPC2_F814W_mock.fits000066400000000000000000000207001526452531300271420ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits000066400000000000000000000341001526452531300334340ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/psf/tests/data/nircam_nrcb4_f150w_fovp101_samp4_npsf1_mock.fits000066400000000000000000000264001526452531300333620ustar00rootroot00000000000000SIMPLE = 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-24be529/photutils/psf/tests/test_bispline.py000066400000000000000000000253541526452531300250240ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _bispline module. """ import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal from scipy.interpolate import RectBivariateSpline from photutils.psf._bispline import bispline_sum, bispline_sum_deriv @pytest.fixture(name='splines') def fixture_splines(): """ Four cubic interpolating splines on a rectangular grid with different x and y sizes, and their shared knots and stacked coefficients. """ rng = np.random.default_rng(1) nx, ny = 41, 33 xg = np.arange(nx, dtype=float) yg = np.arange(ny, dtype=float) splines = [RectBivariateSpline(xg, yg, rng.normal(size=(nx, ny)), kx=3, ky=3, s=0) for _ in range(4)] tx, ty, _ = splines[0].tck coeffs = np.array([spline.tck[2] for spline in splines]) return splines, np.ascontiguousarray(tx), np.ascontiguousarray(ty), coeffs def _points(nx=41, ny=33, n=500, seed=2): rng = np.random.default_rng(seed) # Include points beyond the grid, which the splines clamp x = rng.uniform(-5, nx + 4, n) y = rng.uniform(-5, ny + 4, n) return x, y def _partial_derivatives(spline, x, y, nx=41, ny=33): """ The x and y partial derivatives of the clamped spline. The derivative splines of scipy give the derivative at the grid edge for a point beyond the grid, where the clamped spline is constant along that axis and its derivative is zero. """ d_x = spline.partial_derivative(1, 0)(x, y, grid=False) d_y = spline.partial_derivative(0, 1)(x, y, grid=False) d_x[(x < 0) | (x > nx - 1)] = 0.0 d_y[(y < 0) | (y > ny - 1)] = 0.0 return d_x, d_y class TestBisplineSum: def test_matches_scipy(self, splines): splines, tx, ty, coeffs = splines grid_idx = np.array([0, 1, 2, 3], dtype=np.intp) weights = np.array([0.3, 0.2, 0.4, 0.1]) x, y = _points() out = np.empty_like(x) bispline_sum(tx, ty, coeffs, grid_idx, weights, x, y, out) expected = sum(w * spline(x, y, grid=False) for w, spline in zip(weights, splines, strict=True)) assert_allclose(out, expected, rtol=1e-13, atol=1e-14) def test_knot_points(self, splines): splines, tx, ty, coeffs = splines grid_idx = np.array([2], dtype=np.intp) weights = np.ones(1) x = np.array([0.0, 1.0, 2.0, 20.0, 39.0, 40.0, 40.0]) y = np.array([0.0, 32.0, 3.0, 16.0, 1.0, 30.0, 32.0]) out = np.empty_like(x) bispline_sum(tx, ty, coeffs, grid_idx, weights, x, y, out) assert_allclose(out, splines[2](x, y, grid=False), rtol=1e-13, atol=1e-14) def test_zero_weight_and_subset(self, splines): splines, tx, ty, coeffs = splines grid_idx = np.array([3, 1], dtype=np.intp) weights = np.array([0.0, 0.7]) x, y = _points(n=50) out = np.empty_like(x) bispline_sum(tx, ty, coeffs, grid_idx, weights, x, y, out) assert_allclose(out, 0.7 * splines[1](x, y, grid=False), rtol=1e-13, atol=1e-14) def test_non_finite_coordinates(self, splines): """ Test that a NaN coordinate gives NaN and that an infinite coordinate is clamped to the knot range like any other point beyond it. The kernels index the arrays without bounds checks, so these coordinates must not select an invalid knot interval. """ splines, tx, ty, coeffs = splines grid_idx = np.array([1], dtype=np.intp) ones = np.ones(1) zeros = np.zeros(1) x = np.array([np.nan, 10.3, np.inf, -np.inf, 10.3, np.nan]) y = np.array([5.2, np.nan, 5.2, 5.2, np.inf, -np.inf]) out = np.empty_like(x) bispline_sum(tx, ty, coeffs, grid_idx, ones, x, y, out) assert np.all(np.isnan(out[[0, 1, 5]])) x_edge = np.array([40.0, 0.0, 10.3]) y_edge = np.array([5.2, 5.2, 32.0]) assert_allclose(out[2:5], splines[1](x_edge, y_edge, grid=False), rtol=1e-13, atol=1e-14) out2 = np.empty_like(x) out_dx = np.empty_like(x) out_dy = np.empty_like(x) bispline_sum_deriv(tx, ty, coeffs, grid_idx, ones, zeros, zeros, -1.0, -1.0, x, y, out2, out_dx, out_dy) assert_equal(out2, out) assert np.all(np.isnan(out_dx[[0, 1, 5]])) assert np.all(np.isnan(out_dy[[0, 1, 5]])) # An infinite coordinate is beyond the knots, so the derivative # along its axis is zero assert np.all(out_dx[[2, 3]] == 0.0) assert out_dy[4] == 0.0 assert np.all(np.isfinite(out_dy[[2, 3]])) assert np.isfinite(out_dx[4]) def test_invalid_inputs(self, splines): _, tx, ty, coeffs = splines grid_idx = np.array([0], dtype=np.intp) weights = np.ones(1) x, y = _points(n=10) out = np.empty(10) with pytest.raises(ValueError, match='inconsistent lengths'): bispline_sum(tx, ty, coeffs, grid_idx, weights, x, y[:5], out) with pytest.raises(ValueError, match='inconsistent lengths'): bispline_sum(tx, ty, coeffs, grid_idx, np.ones(2), x, y, out) with pytest.raises(ValueError, match='does not match the knot'): bispline_sum(tx, ty, coeffs[:, :-1].copy(), grid_idx, weights, x, y, out) with pytest.raises(ValueError, match='too short'): bispline_sum(tx[:7].copy(), ty, coeffs, grid_idx, weights, x, y, out) for bad_idx in (-1, 4): with pytest.raises(ValueError, match='grid_idx'): bispline_sum(tx, ty, coeffs, np.array([bad_idx], dtype=np.intp), weights, x, y, out) class TestBisplineSumDeriv: def test_matches_scipy(self, splines): splines, tx, ty, coeffs = splines grid_idx = np.array([0, 1, 2, 3], dtype=np.intp) weights = np.array([0.3, 0.2, 0.4, 0.1]) dw_dx = np.array([-0.01, 0.01, -0.02, 0.02]) dw_dy = np.array([0.03, -0.03, 0.01, -0.01]) scale_x, scale_y = 4.0, 2.0 x, y = _points() out = np.empty_like(x) out_dx = np.empty_like(x) out_dy = np.empty_like(x) bispline_sum_deriv(tx, ty, coeffs, grid_idx, weights, dw_dx, dw_dy, scale_x, scale_y, x, y, out, out_dx, out_dy) values = [spline(x, y, grid=False) for spline in splines] d_x, d_y = zip(*[_partial_derivatives(spline, x, y) for spline in splines], strict=True) expected = sum(w * v for w, v in zip(weights, values, strict=True)) expected_dx = sum(dwx * v - scale_x * w * dx for w, dwx, v, dx in zip(weights, dw_dx, values, d_x, strict=True)) expected_dy = sum(dwy * v - scale_y * w * dy for w, dwy, v, dy in zip(weights, dw_dy, values, d_y, strict=True)) assert_allclose(out, expected, rtol=1e-13, atol=1e-14) assert_allclose(out_dx, expected_dx, rtol=1e-13, atol=1e-13) assert_allclose(out_dy, expected_dy, rtol=1e-13, atol=1e-13) def test_skips_unused_planes(self, splines): splines, tx, ty, coeffs = splines grid_idx = np.array([0, 3], dtype=np.intp) weights = np.array([0.0, 1.0]) zeros = np.zeros(2) x, y = _points(n=50) out = np.empty_like(x) out_dx = np.empty_like(x) out_dy = np.empty_like(x) bispline_sum_deriv(tx, ty, coeffs, grid_idx, weights, zeros, zeros, 1.0, 1.0, x, y, out, out_dx, out_dy) spline = splines[3] d_x, d_y = _partial_derivatives(spline, x, y) assert_allclose(out, spline(x, y, grid=False), rtol=1e-13, atol=1e-14) assert_allclose(out_dx, -d_x, rtol=1e-13, atol=1e-13) assert_allclose(out_dy, -d_y, rtol=1e-13, atol=1e-13) def test_zero_derivative_beyond_knots(self, splines): """ Test that the derivative along an axis is zero for a point beyond the knot range along that axis, where the clamped spline is constant, and that the edge points keep the edge derivative. """ splines, tx, ty, coeffs = splines grid_idx = np.array([1], dtype=np.intp) ones = np.ones(1) zeros = np.zeros(1) x = np.array([-0.5, 40.5, 20.3, 20.3, -2.0, 0.0, 40.0]) y = np.array([10.2, 10.2, -0.5, 32.5, 35.0, 0.0, 32.0]) out = np.empty_like(x) out_dx = np.empty_like(x) out_dy = np.empty_like(x) bispline_sum_deriv(tx, ty, coeffs, grid_idx, ones, zeros, zeros, -1.0, -1.0, x, y, out, out_dx, out_dy) assert np.all(out_dx[[0, 1, 4]] == 0.0) assert np.all(out_dy[[2, 3, 4]] == 0.0) assert np.all(out_dy[[0, 1, 5, 6]] != 0.0) assert np.all(out_dx[[2, 3, 5, 6]] != 0.0) # The derivatives match central differences of the values eps = 1e-6 def values(xval, yval): result = np.empty_like(xval) bispline_sum(tx, ty, coeffs, grid_idx, ones, xval, yval, result) return result inside = slice(0, 5) # the edge points have one-sided derivatives num_dx = (values(x + eps, y) - values(x - eps, y)) / (2 * eps) num_dy = (values(x, y + eps) - values(x, y - eps)) / (2 * eps) assert_allclose(out_dx[inside], num_dx[inside], atol=1e-7) assert_allclose(out_dy[inside], num_dy[inside], atol=1e-7) def test_invalid_inputs(self, splines): _, tx, ty, coeffs = splines grid_idx = np.array([0], dtype=np.intp) ones = np.ones(1) x, y = _points(n=10) out = np.empty(10) with pytest.raises(ValueError, match='inconsistent lengths'): bispline_sum_deriv(tx, ty, coeffs, grid_idx, ones, np.ones(2), ones, 1.0, 1.0, x, y, out, out, out) with pytest.raises(ValueError, match='does not match the knot'): bispline_sum_deriv(tx, ty, coeffs[:, 1:].copy(), grid_idx, ones, ones, ones, 1.0, 1.0, x, y, out, out, out) with pytest.raises(ValueError, match='too short'): bispline_sum_deriv(tx, ty[:7].copy(), coeffs, grid_idx, ones, ones, ones, 1.0, 1.0, x, y, out, out, out) for bad_idx in (-1, 4): with pytest.raises(ValueError, match='grid_idx'): bispline_sum_deriv(tx, ty, coeffs, np.array([bad_idx], dtype=np.intp), ones, ones, ones, 1.0, 1.0, x, y, out, out, out) astropy-photutils-24be529/photutils/psf/tests/test_components.py000066400000000000000000000636371526452531300254120ustar00rootroot00000000000000# 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_allclose, assert_equal from photutils.detection import DAOStarFinder from photutils.psf import CircularGaussianPRF from photutils.psf._components import (PSFDataProcessor, PSFFitter, PSFResultsAssembler, _get_flat_model, _ModelImageMaker) 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 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 = 'must be a Quantity column' with pytest.raises(ValueError, match=match_str): processor.normalize_init_units(init_params, 'flux_init') def test_normalize_init_units_column_with_unit(self, param_mapper): """ Regression test that a plain Table column carrying a unit attribute is converted to the data unit instead of raising an error claiming the column has no units. """ processor = PSFDataProcessor(param_mapper, (7, 7)) processor.data_unit = u.Jy init_params = Table({'flux_init': [1000.0, 2000.0]}) init_params['flux_init'].unit = u.mJy result = processor.normalize_init_units(init_params, 'flux_init') assert_equal(np.asarray(result['flux_init']), [1.0, 2.0]) 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 nonfinite local_bkg values. Nonfinite 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 - nonfinite 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, ) 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 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_get_flat_model_without_class_cache(self, psf_model, param_mapper): """ Test that _get_flat_model creates a new flat-model class on every call when no class cache is given. """ sources = Table({ 'id': [1, 2], 'x_init': [10.0, 20.0], 'y_init': [15.0, 25.0], 'flux_init': [100.0, 200.0], }) model1 = _get_flat_model(sources, psf_model, param_mapper) model2 = _get_flat_model(sources, psf_model, param_mapper) assert type(model1) is not type(model2) assert model1.flux_0.value == 100.0 assert model1.x_0_1.value == 20.0 # With a cache, the class is created once and then reused cache = {} model3 = _get_flat_model(sources, psf_model, param_mapper, class_cache=cache) model4 = _get_flat_model(sources, psf_model, param_mapper, class_cache=cache) assert type(model3) is type(model4) def test_make_psf_model_no_stale_class_cache(self): """ Regression test that flat-model classes are not shared between PSFFitter instances with different PSF models of the same class. """ sources = Table({ 'id': [1, 2], 'x_init': [10.0, 20.0], 'y_init': [15.0, 25.0], 'flux_init': [100.0, 200.0], 'fwhm_init': [2.7, 2.7], }) psf_model1 = CircularGaussianPRF(flux=1, fwhm=2.7) fitter1 = PSFFitter(psf_model1, _PSFParameterMapper(psf_model1)) flat_model1 = fitter1.make_psf_model(sources) assert flat_model1.fwhm_0.fixed is True psf_model2 = CircularGaussianPRF(flux=1, fwhm=2.7) psf_model2.fwhm.fixed = False fitter2 = PSFFitter(psf_model2, _PSFParameterMapper(psf_model2)) flat_model2 = fitter2.make_psf_model(sources) assert flat_model2.fwhm_0.fixed is False assert flat_model2.fwhm_1.fixed is False def test_flat_model_preserves_bounds(self, psf_model, param_mapper): """ Regression test that flat models preserve the parameter bounds of the base PSF model. """ assert psf_model.fwhm.bounds[0] is not None 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], }) flat_model = fitter.make_psf_model(sources) assert flat_model.fwhm_0.bounds == psf_model.fwhm.bounds assert flat_model.fwhm_1.bounds == psf_model.fwhm.bounds 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, ) assert assembler.param_mapper is param_mapper assert assembler.fit_shape == fit_shape def test_define_flags_no_valid_mask(self, param_mapper): """ Test that define_flags treats every source as valid when valid_mask is None. """ assembler = PSFResultsAssembler(param_mapper, (7, 7)) results_tbl = QTable({'x_fit': [11.0, 30.0], 'y_fit': [10.0, 30.0], 'flux_fit': [100.0, 100.0], 'n_pixels_fit': [49, 49]}) init_params = QTable({'x_init': [10.0, 30.0], 'y_init': [10.0, 30.0], 'local_bkg': [0.0, 0.0]}) fitted_models_table = Table({'id': [1, 2], 'x_0': [11.0, 30.0], 'x_0_fixed': [False, False], 'x_0_bounds': [(9.0, 11.0), (29.0, 31.0)]}) fit_info = [{'param_cov': np.eye(3)}, {'param_cov': np.eye(3)}] flags = assembler.define_flags(results_tbl, (50, 50), None, fit_info, fitted_models_table, None, None, init_params) assert_equal(flags, [32, 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 values for fitting assert psf_model_fit.x_0.value == 12.0 assert psf_model_fit.y_0.value == 12.0 assert psf_model_fit.flux.value == 100.0 class TestModelImageMaker: """ Tests for the _ModelImageMaker class. """ def test_local_bkg_none(self, psf_model): """ Test that include_local_bkg=True with local_bkg=None treats the local background as zero. """ params = QTable({'x_0': [12.0], 'y_0': [12.0], 'flux': [100.0]}) maker = _ModelImageMaker(psf_model, params) image = maker.make_model_image((25, 25), psf_shape=(9, 9)) image_bkg = maker.make_model_image((25, 25), psf_shape=(9, 9), include_local_bkg=True) assert_allclose(image_bkg, image) def test_local_bkg_nonfinite(self, psf_model): """ Test that nonfinite local background values are treated as zero. """ params = QTable({'x_0': [8.0, 17.0], 'y_0': [8.0, 17.0], 'flux': [100.0, 50.0]}) maker = _ModelImageMaker(psf_model, params, local_bkg=np.array([np.nan, 0.0])) image = maker.make_model_image((25, 25), psf_shape=(9, 9)) image_bkg = maker.make_model_image((25, 25), psf_shape=(9, 9), include_local_bkg=True) assert_allclose(image_bkg, image) astropy-photutils-24be529/photutils/psf/tests/test_epsf_builder.py000066400000000000000000005461671526452531300256740ustar00rootroot00000000000000# 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.modeling.models import Gaussian2D from astropy.nddata import NDData from astropy.table import Table from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_array_equal from photutils.centroids import (centroid_1dg, centroid_2dg, centroid_com, centroid_quadratic, centroid_symmetry) from photutils.datasets import make_model_image from photutils.psf import (CircularGaussianPRF, EPSFBuilder, EPSFBuildResults, EPSFFitter, EPSFStar, EPSFStars, ImagePSF, extract_stars, make_psf_model_image) from photutils.psf.epsf_builder import (_alias_filter_band, _CoordinateTransformer, _EPSFValidator, _measure_fwhm, _ProgressReporter, _SmoothingKernel, _suppress_alias_modes) from photutils.psf.epsf_stars import LinkedEPSFStar from photutils.utils._optional_deps import HAS_MATPLOTLIB, HAS_TQDM from photutils.utils.exceptions import PhotutilsDeprecationWarning @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 _insert_nan_near_star(epsf_test_data, *, index=0): """ Copy the test image and set a pixel one row above the given star center to NaN. Returns the NDData copy and the true (x, y) center of that star. """ data = epsf_test_data['data'].copy() xtrue = epsf_test_data['init_stars']['x'][index] ytrue = epsf_test_data['init_stars']['y'][index] iy = int(np.round(ytrue)) + 1 ix = int(np.round(xtrue)) data[iy, ix] = np.nan return NDData(data), (xtrue, ytrue) class _MockWCS: """ Mock WCS with an identity pixel-to-world transform. """ world_axis_units = ('deg', 'deg') world_axis_object_components = (('celestial', 0, 'spherical.lon.degree'), ('celestial', 1, 'spherical.lat.degree')) def pixel_to_world_values(self, x, y): return x, y def world_to_pixel_values(self, ra, dec): return ra, dec 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', PhotutilsDeprecationWarning) return EPSFFitter(**kwargs) def test_build_epsf_linked_stars(): """ Regression test that the public builder accepts LinkedEPSFStar inputs. """ yy, xx = np.indices((11, 11)) sig = 2.5 / 2.3548 star_data = np.exp(-((xx - 5.0)**2 + (yy - 5.0)**2) / (2 * sig**2)) stars_list = [EPSFStar(star_data.copy(), cutout_center=(5.0, 5.0), origin=(0, 0), wcs_large=_MockWCS()) for _ in range(2)] linked = LinkedEPSFStar(stars_list) stars = EPSFStars([linked]) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) result = builder(stars) assert result.epsf.data.shape[0] >= 11 @pytest.mark.parametrize('constrain_fluxes', [True, False]) def test_build_epsf_linked_stars_constrain_fluxes(constrain_fluxes): """ Test that the fluxes of linked stars are constrained to their mean after fitting when ``constrain_fluxes`` is `True`. """ yy, xx = np.indices((11, 11)) sig = 2.5 / 2.3548 star_data = np.exp(-((xx - 5.0)**2 + (yy - 5.0)**2) / (2 * sig**2)) # The same star with different flux scales in the two images stars_list = [EPSFStar(scale * star_data, cutout_center=(5.0, 5.0), origin=(0, 0), wcs_large=_MockWCS()) for scale in (1.0, 3.0)] linked = LinkedEPSFStar(stars_list) stars = EPSFStars([linked]) builder = EPSFBuilder(oversampling=1, maxiters=2, constrain_fluxes=constrain_fluxes, progress_bar=False) assert builder.constrain_fluxes is constrain_fluxes result = builder(stars) fluxes = result.fitted_stars[0].flux if constrain_fluxes: assert_allclose(fluxes[0], fluxes[1]) else: assert_allclose(fluxes[1] / fluxes[0], 3.0, rtol=0.05) 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_minimum_shape(self): """ Test that the minimum-shape rule matches compute_epsf_shape (add 1 only when the star size times oversampling is even). """ data = np.ones((11, 11)) star = EPSFStar(data, cutout_center=(5, 5)) stars = EPSFStars([star]) # 11 * 1 = 11 (odd), so shape (11, 11) is compatible _EPSFValidator.validate_shape_compatibility(stars, (1, 1), shape=(11, 11)) # 11 * 2 = 22 (even), so the minimum shape is (23, 23) match = r'Requested ePSF shape .* is incompatible' with pytest.raises(ValueError, match=match): _EPSFValidator.validate_shape_compatibility(stars, (2, 2), shape=(21, 21)) _EPSFValidator.validate_shape_compatibility(stars, (2, 2), shape=(23, 23)) 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 nonfinite 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 nonpositive 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 nonpositive 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 TestEPSFBuildResults: """ Tests for the EPSFBuildResults class. """ def test_creation(self): """ Test EPSFBuildResults 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 = EPSFBuildResults( 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 EPSFBuildResults 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 = EPSFBuildResults( 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 EPSFBuildResults.__getitem__ with invalid index. """ data = np.ones((5, 5)) psf = ImagePSF(data) stars = EPSFStars([]) result = EPSFBuildResults( 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 = 'EPSFBuildResults 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 EPSFBuildResults iteration (tuple unpacking). """ data = np.ones((5, 5)) psf = ImagePSF(data) stars = EPSFStars([]) result = EPSFBuildResults( 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 EPSFBuildResults 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) assert type(result.final_center_accuracy) is float assert isinstance(result.n_excluded_stars, int) assert isinstance(result.excluded_star_indices, list) def test_deprecated_name(self): """ Test that the old EPSFBuildResult name is a deprecated alias. """ from photutils import psf from photutils.psf import epsf_builder match = 'EPSFBuildResult is deprecated' with pytest.warns(PhotutilsDeprecationWarning, match=match): cls = psf.EPSFBuildResult assert cls is EPSFBuildResults with pytest.warns(PhotutilsDeprecationWarning, match=match): cls = epsf_builder.EPSFBuildResult assert cls is EPSFBuildResults 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(PhotutilsDeprecationWarning, 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 with a fitter whose signature does not accept weights. The weights are dropped based on the fitter call signature. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] # Create a fitter whose signature has no weights keyword class NoWeightsFitter: def __init__(self): self.fit_info = {'ierr': 1} def __call__(self, model, x, y, z): # noqa: ARG002 return model no_weights_fitter = NoWeightsFitter() fitter = _make_epsf_fitter(fitter=no_weights_fitter) assert not fitter._fitter_accepts_weights # Fit the stars - the fitter is called without weights 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'] 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. Excluded stars within a LinkedEPSFStar are passed through unchanged while the remaining linked stars are fitted. """ stars = epsf_fitter_data['stars'] epsf = epsf_fitter_data['epsf'] 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 def test_fit_star_center_outside_cutout(self, epsf_fitter_data): """ Test EPSFFitter._fit_star when the fitted center falls outside the star cutout. """ epsf = epsf_fitter_data['epsf'] class ShiftedFitter: """ Mock fitter that shifts the model center far outside the star cutout. """ def __call__(self, model, **kwargs): # noqa: ARG002 fitted = model.copy() fitted.x_0 = 1000.0 return fitted star_data = np.ones((11, 11)) star = EPSFStar(star_data, cutout_center=(5.0, 5.0)) stars = EPSFStars([star]) fitter = _make_epsf_fitter(fitter=ShiftedFitter(), fit_boxsize=None) fitted_stars = fitter(epsf, stars) # The star center and flux must be left unchanged fitted_star = fitted_stars.all_stars[0] assert fitted_star._fit_error_status == 3 assert_allclose(fitted_star.cutout_center, (5.0, 5.0)) assert fitted_star.flux == star.flux def test_fit_star_masked_pixel_in_fit_box(self, epsf_test_data): """ Test that a masked (NaN) pixel inside the fit box is excluded from the fit instead of raising a fitter error. """ nddata, (xtrue, ytrue) = _insert_nan_near_star(epsf_test_data) match = 'Input data array contains invalid data' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(nddata, epsf_test_data['init_stars'][:4], size=11) assert np.isnan(stars[0].data).any() builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) epsf, _ = builder(stars) fitter = _make_epsf_fitter(fit_boxsize=5) fitted_stars = fitter(epsf, stars) fitted_star = fitted_stars.all_stars[0] assert fitted_star._fit_error_status == 0 assert_allclose(fitted_star.center, (xtrue, ytrue), atol=0.1) def test_fit_star_all_masked_fit_box(self, epsf_fitter_data): """ Test that a star whose fit box is fully masked is flagged as a fit failure and left unchanged. """ epsf = epsf_fitter_data['epsf'] star_data = np.ones((11, 11)) weights = np.ones((11, 11)) weights[3:8, 3:8] = 0.0 star = EPSFStar(star_data, weights=weights, cutout_center=(5.0, 5.0)) stars = EPSFStars([star]) fitter = _make_epsf_fitter(fit_boxsize=5) fitted_stars = fitter(epsf, stars) fitted_star = fitted_stars.all_stars[0] assert fitted_star._fit_error_status == 4 assert_allclose(fitted_star.cutout_center, (5.0, 5.0)) assert fitted_star.flux == star.flux 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 [[], 'a']: match = 'sigma_clip must be an astropy.stats.SigmaClip instance' with pytest.raises(TypeError, match=match): EPSFBuilder(sigma_clip=sigma_clip) match = 'fitter_maxiters must be an integer' with pytest.raises(TypeError, match=match): EPSFBuilder(fitter_maxiters=2.5) match = 'fitter_maxiters must be a positive integer' with pytest.raises(ValueError, match=match): EPSFBuilder(fitter_maxiters=-5) 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 'auto' assert builder1.fit_shape == 'auto' assert builder1.smoothing_kernel == 'auto' 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(PhotutilsDeprecationWarning, 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, centroid_symmetry, ]) 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) def test_recentering_default(self): """ Test that the ePSF is centered on its symmetry center by default. """ builder = EPSFBuilder(progress_bar=False) assert builder.recentering_func is centroid_symmetry def test_recentering_symmetry_small_box(self, epsf_test_data): """ Test that the center of mass is used when the recentering box is too small for centroid_symmetry. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:4], size=11) kwargs = {'oversampling': 1, 'recentering_boxsize': 3, 'maxiters': 3, 'progress_bar': False} match = 'centered on its center of mass' with pytest.warns(AstropyUserWarning, match=match): builder = EPSFBuilder(**kwargs) epsf, _ = builder(stars) epsf_com, _ = EPSFBuilder(recentering_func=centroid_com, **kwargs)(stars) assert np.all(np.isfinite(epsf.data)) assert_allclose(epsf.data, epsf_com.data) @pytest.mark.parametrize('oversampling', [1, 2]) def test_recentering_symmetry_offset_stars(self, oversampling): """ Test that the ePSF is centered if the input star positions share an offset that places it outside of the search area of centroid_symmetry. """ model = CircularGaussianPRF(fwhm=1.5) data, params = make_psf_model_image((200, 200), model, 36, model_shape=(11, 11), flux=(5000, 20000), min_separation=20, border_size=15, seed=0) init_stars = Table() init_stars['x'] = params['x_0'] + 1.5 init_stars['y'] = params['y_0'] - 1.5 stars = extract_stars(NDData(data), init_stars, size=15) builder = EPSFBuilder(oversampling=oversampling, progress_bar=False) result = builder(stars) assert result.n_excluded_stars == 0 centers = result.fitted_stars.center_flat assert_allclose(centers[:, 0], params['x_0'], atol=0.02) assert_allclose(centers[:, 1], params['y_0'], atol=0.02) def test_center_asymmetry_small_epsf(self, epsf_test_data): """ Test that center_asymmetry is None for an ePSF that is not larger than the 5x5 pixel box in which it is measured. """ init_stars = epsf_test_data['init_stars'][:10] kwargs = {'oversampling': 1, 'maxiters': 2, 'progress_bar': False} stars = extract_stars(epsf_test_data['nddata'], init_stars, size=5) result = EPSFBuilder(**kwargs)(stars) assert result.center_asymmetry is None stars = extract_stars(epsf_test_data['nddata'], init_stars, size=7) result = EPSFBuilder(**kwargs)(stars) assert result.center_asymmetry.shape == (2,) @pytest.mark.parametrize('oversampling', [1, 2]) def test_recentering_symmetry_asymmetric_psf(self, oversampling): """ Test that centroid_symmetry centers an asymmetric ePSF on its core and that centroid_com centers it on its center of mass. """ # A Gaussian core with a faint lobe 3 pixels from its center model_oversampling = 4 size = 21 * model_oversampling + 1 center = (size - 1) / 2 yy, xx = np.mgrid[:size, :size] std = 1.2 * model_oversampling psf_data = np.exp(-((xx - center) ** 2 + (yy - center) ** 2) / (2 * std ** 2)) x_lobe = center + 3 * model_oversampling y_lobe = center + model_oversampling psf_data += 0.15 * np.exp(-((xx - x_lobe) ** 2 + (yy - y_lobe) ** 2) / (2 * model_oversampling ** 2)) psf_model = ImagePSF(psf_data, oversampling=model_oversampling) rng = np.random.default_rng(0) xgrid, ygrid = np.meshgrid(np.arange(30, 300, 45), np.arange(30, 300, 45)) n_stars = xgrid.size params = Table() params['x_0'] = xgrid.ravel() + rng.uniform(-0.5, 0.5, n_stars) params['y_0'] = ygrid.ravel() + rng.uniform(-0.5, 0.5, n_stars) params['flux'] = rng.uniform(5000, 20000, n_stars) data = make_model_image((315, 315), psf_model, params, model_shape=(21, 21)) init_stars = Table() init_stars['x'] = params['x_0'] init_stars['y'] = params['y_0'] stars = extract_stars(NDData(data), init_stars, size=21) # The recentering box of the ePSF in oversampled pixels half_box = (5 * oversampling) // 2 centers = {} asymmetry = {} for func in (centroid_com, centroid_symmetry): # The symmetry center is the default kwargs = {} if func is centroid_com: kwargs['recentering_func'] = func builder = EPSFBuilder(oversampling=oversampling, maxiters=5, progress_bar=False, **kwargs) result = builder(stars) epsf = result.epsf asymmetry[func] = result.center_asymmetry idx = (epsf.data.shape[0] - 1) // 2 slc = slice(idx - half_box, idx + half_box + 1) box = epsf.data[slc, slc] centers[func] = (centroid_com(box) - half_box, centroid_symmetry(box) - half_box) com, symmetry = centers[centroid_com] assert_allclose(com, 0, atol=1.0e-3) assert np.hypot(*symmetry) > 0.03 com, symmetry = centers[centroid_symmetry] assert_allclose(symmetry, 0, atol=1.0e-3) assert np.hypot(*com) > 0.03 # The offset between the two centers (in detector pixels) does # not depend on the recentering function of the build. It is # measured iteratively, so it is only close to the single # measurement of the symmetry center made above. _, symmetry = centers[centroid_com] assert asymmetry[centroid_com].shape == (2,) assert_allclose(asymmetry[centroid_com], -symmetry / oversampling, atol=0.01) assert_allclose(asymmetry[centroid_symmetry], asymmetry[centroid_com], atol=5.0e-3) @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_even_shape_made_odd(self, epsf_test_data): """ Test that an even input shape is made odd by adding one, with a warning, as documented. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) match = 'has even values' with pytest.warns(AstropyUserWarning, match=match): builder = EPSFBuilder(oversampling=1, shape=(24, 24), maxiters=1, progress_bar=False) assert_array_equal(builder.shape, (25, 25)) epsf, _ = builder(stars) assert epsf.data.shape == (25, 25) def test_shape_equal_to_star_size(self, epsf_test_data): """ Test that a shape equal to the star size times the (odd product) oversampling is accepted, consistent with the automatically derived shape. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) builder = EPSFBuilder(oversampling=1, shape=(11, 11), maxiters=1, progress_bar=False) epsf, _ = builder(stars) assert epsf.data.shape == (11, 11) def test_sigma_clip_none(self, epsf_test_data): """ Test that sigma_clip=None (no sigma clipping) is supported. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, sigma_clip=None, progress_bar=False) assert builder._sigma_clip is None result = builder(stars) assert result.epsf is not None assert result.epsf.data.shape == (11, 11) def test_build_epsf_resume(self, epsf_test_data): """ Test resuming a build from a previously built ePSF and that a mismatched initial-ePSF oversampling is rejected. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:5], size=11) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) result1 = builder.build_epsf(stars) # Resume from the previous ePSF with matching oversampling result2 = builder.build_epsf(stars, epsf=result1.epsf) assert isinstance(result2, EPSFBuildResults) assert result2.epsf.data.shape == result1.epsf.data.shape # A mismatched initial-ePSF oversampling raises an error builder4 = EPSFBuilder(oversampling=4, maxiters=2, progress_bar=False) epsf_bad = ImagePSF(np.zeros((45, 45)), oversampling=2) match = 'oversampling' with pytest.raises(ValueError, match=match): builder4.build_epsf(stars, epsf=epsf_bad) def test_build_results_len(self, epsf_test_data): """ Test that EPSFBuildResults has length 2 for tuple unpacking. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False) result = builder(stars) assert len(result) == 2 def test_numpy_scalar_parameters(self): """ Test that NumPy scalar center_accuracy and maxiters values are accepted, while bool maxiters values are rejected. """ builder = EPSFBuilder(center_accuracy=np.float32(0.01), maxiters=np.int64(5)) assert builder.maxiters == 5 match = 'maxiters must be an integer' with pytest.raises(TypeError, match=match): EPSFBuilder(maxiters=True) def test_smoothing_kernel_1d_raises(self): """ Test that a 1D custom smoothing kernel raises a ValueError. """ match = 'smoothing_kernel must be a 2D array' with pytest.raises(ValueError, match=match): _SmoothingKernel.get_kernel(np.ones(5)) def test_coord_transformer_deprecated(self): """ Test that the public coord_transformer attribute is deprecated. """ builder = EPSFBuilder(maxiters=1, progress_bar=False) match = 'The coord_transformer attribute is deprecated' with pytest.warns(PhotutilsDeprecationWarning, match=match): transformer = builder.coord_transformer assert transformer is builder._coord_transformer 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, fraction, max_dist_sq, common_dist_sq, _) = builder._check_convergence(stars, centers, fit_failed) # Should return False (not converged) when no good stars assert converged is False assert fraction == 0.0 # No center movement could be measured assert np.isnan(max_dist_sq) assert np.isnan(common_dist_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_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 @pytest.mark.parametrize('id_label', [None, 'star_a']) def test_star_exclusion_id_label(self, epsf_test_data, id_label): """ Regression test that the exclusion warning works for the default (None) and string star id labels. """ 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) for star in stars.all_stars: star.id_label = id_label builder = EPSFBuilder(oversampling=1, maxiters=5, progress_bar=False) match = 'has been excluded from ePSF fitting' with pytest.warns(AstropyUserWarning, match=match): builder(stars) 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, fit_shape=5, 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 tracks the excluded star indices. """ # The first star is placed near the image corner so that # its fitting region extends beyond the cutout and it is # excluded after repeated fit failures (same setup as # test_star_exclusion). 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' with pytest.warns(AstropyUserWarning, match=match): result = builder(stars) assert result.excluded_star_indices == [0] assert result.n_excluded_stars == 1 assert all(type(idx) is int for idx in result.excluded_star_indices) @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 _find_epsf_center 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._find_epsf_center(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._find_epsf_center( 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._find_epsf_center( 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) 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) 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_build_epsf_masked_pixel_in_fit_shape(self, epsf_test_data): """ Regression test for a NaN pixel inside the fit shape. A masked pixel next to a star center must be excluded from the fit instead of raising NonFiniteValueError in the astropy fitter. """ nddata, (xtrue, ytrue) = _insert_nan_near_star(epsf_test_data) match = 'Input data array contains invalid data' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(nddata, epsf_test_data['init_stars'][:25], size=25) assert np.isnan(stars[0].data).any() builder = EPSFBuilder(oversampling=2, maxiters=3, progress_bar=False) epsf, fitted_stars = builder(stars) assert np.all(np.isfinite(epsf.data)) fitted_star = fitted_stars.all_stars[0] assert fitted_star._fit_error_status == 0 assert_allclose(fitted_star.center, (xtrue, ytrue), atol=0.1) def test_fit_star_all_masked_fit_shape(self, epsf_test_data): """ Test EPSFBuilder._fit_star with a fully masked fit shape. """ 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_data = np.ones((11, 11)) weights = np.ones((11, 11)) weights[3:8, 3:8] = 0.0 star = EPSFStar(star_data, weights=weights, cutout_center=(5.0, 5.0)) fitted_star = builder._fit_star(epsf, star) assert fitted_star._fit_error_status == 4 assert_allclose(fitted_star.cutout_center, (5.0, 5.0)) assert fitted_star.flux == star.flux def test_process_iteration_excludes_all_masked(self, epsf_test_data): """ Test that _process_iteration excludes a star whose fit shape contains no unmasked pixels. """ 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_data = np.ones((11, 11)) weights = np.ones((11, 11)) weights[3:8, 3:8] = 0.0 masked_star = EPSFStar(star_data, weights=weights, cutout_center=(5.0, 5.0)) stars = EPSFStars([masked_star, stars.all_stars[1]]) match = 'its fitting region contains no unmasked pixels' with pytest.warns(AstropyUserWarning, match=match): _, stars_new, fit_failed = builder._process_iteration( stars, epsf, iter_num=4) assert fit_failed[0] assert not fit_failed[1] assert stars_new.all_stars[0]._excluded_from_fit assert stars_new.all_stars[0]._fit_error_status == 4 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' with pytest.raises(ValueError, match=match): builder._normalize_epsf(epsf_data) # Nonfinite sums are also rejected epsf_data = np.full((5, 5), np.nan) with pytest.raises(ValueError, match=match): builder._normalize_epsf(epsf_data) def test_normalize_epsf_nonfinite_recentering(self, epsf_test_data): """ Test that a recentering function returning nonfinite centroids produces a clear error during building. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:3], size=11) def bad_centroid(data, *, mask=None): # noqa: ARG001 return (np.nan, np.nan) builder = EPSFBuilder(oversampling=1, maxiters=2, recentering_func=bad_centroid, progress_bar=False) match = 'Cannot normalize ePSF' with pytest.raises(ValueError, match=match): builder(stars) @pytest.mark.parametrize(('oversamp', 'fwhm'), [(1, 7.0), (2, 7.0), (3, 7.0), (4, 7.0), (5, 7.0), (2, 3.0), (4, 2.0)]) def test_build_oversampling(self, oversamp, fwhm): """ 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 it matches the true ePSF, i.e., the PSF integrated over a full input pixel at each oversampled grid offset. The undersampled cases are sensitive to this definition of the truth. """ 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] n_stars = oversamp**2 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((n_stars,), 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 ePSF matches the true ePSF. The true ePSF # at each oversampled grid point is the PSF integrated over # a full input pixel centered at that grid offset (in input # pixel units), which is what the pixel-integrated PRF model # evaluates. Note that evaluating the PRF with the FWHM scaled # by the oversampling factor on the oversampled grid would # instead integrate over 1 / oversamp of a pixel and is not # the ePSF. size = epsf.data.shape[0] cen = (size - 1) / 2 model = CircularGaussianPRF(flux=1, x_0=0, y_0=0, fwhm=fwhm) yy, xx = np.mgrid[0:size, 0:size] psf = model((xx - cen) / oversamp, (yy - cen) / oversamp) assert_allclose(psf.sum(), expected_sum, rtol=1e-3) # The default 'auto' smoothing kernel (0.7 FWHM wide) lowers # the peak of a noiseless ePSF by a few tenths of a percent. assert_allclose(epsf.data, psf, atol=5e-3 * psf.max()) # 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 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 a fitter whose signature does not accept weights. The weights are dropped based on the fitter call signature. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:2], size=11) # Create a fitter whose signature has no weights keyword class NoWeightsFitter: def __init__(self): self.fit_info = {'ierr': 1} def __call__(self, model, x, y, z, maxiter=None): # noqa: ARG002 return model no_weights_fitter = NoWeightsFitter() builder = EPSFBuilder(oversampling=1, maxiters=1, progress_bar=False, fitter=no_weights_fitter) assert not builder._fitter_accepts_weights # 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 holding an invalid star type (bypass the # EPSFStars constructor validation) class InvalidStar: pass invalid_stars = EPSFStars([]) invalid_stars._data.append(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) # The fitted star _fit_error_status should be 3 assert fitted_star._fit_error_status == 3 # The fitted star cutout_center should not have been updated assert_array_equal(fitted_star.cutout_center, original_center) # The fitted star 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) # The fitted_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 def _make_gaussian_star_data(): yy, xx = np.indices((11, 11)) sig = 2.5 / 2.3548 return np.exp(-((xx - 5.0)**2 + (yy - 5.0)**2) / (2 * sig**2)) @pytest.mark.parametrize('oversampling', [1, 4]) def test_build_no_zero_edges(epsf_test_data, oversampling): """ Regression test for an all-zero row and column at one edge of the ePSF. The recentering step shifts the ePSF with its spline, so the edge row and column on one side of the grid fall outside the original grid. They must be extrapolated rather than filled with zero. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:30], size=11) builder = EPSFBuilder(oversampling=oversampling, maxiters=5, progress_bar=False) result = builder(stars) data = result.epsf.data edges = [data[0], data[-1], data[:, 0], data[:, -1]] for edge in edges: assert np.any(edge != 0) # Opposite edges of the symmetric PSF have comparable (near-zero) # values relative to the peak. atol = 5e-3 * data.max() assert_allclose(data[0].mean(), data[-1].mean(), atol=atol) assert_allclose(data[:, 0].mean(), data[:, -1].mean(), atol=atol) # A single build step from an off-center initial ePSF keeps its # edges after the recentering shift. epsf = builder._create_initial_epsf(stars) yy, xx = np.indices(epsf.data.shape) cen = (epsf.data.shape[0] - 1) / 2 + 0.7 * oversampling sigma = 2.7 / 2.3548 * oversampling epsf.data[:] = np.exp(-((xx - cen)**2 + (yy - cen)**2) / (2 * sigma**2)) stepped = builder._build_epsf_step(stars, epsf=epsf) data = stepped.data for edge in (data[0], data[-1], data[:, 0], data[:, -1]): assert np.any(edge != 0) def test_build_epsf_initial_epsf_too_small(): """ Regression test that build_epsf validates the shape of a provided initial ePSF instead of silently truncating the result. """ star_data = _make_gaussian_star_data() stars = EPSFStars([EPSFStar(star_data.copy(), cutout_center=(5.0, 5.0)) for _ in range(2)]) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) epsf = ImagePSF(np.zeros((5, 5)), oversampling=1) match = 'incompatible with' with pytest.raises(ValueError, match=match): builder.build_epsf(stars, epsf=epsf) def test_build_epsf_fully_excluded_linked_star(): """ Regression test that a fully excluded LinkedEPSFStar does not emit a constrain-centers warning on every build iteration. """ star_data = _make_gaussian_star_data() good_stars = [EPSFStar(star_data.copy(), cutout_center=(5.0, 5.0)) for _ in range(2)] linked_members = [EPSFStar(star_data.copy(), cutout_center=(5.0, 5.0), origin=(0, 0), wcs_large=_MockWCS()) for _ in range(2)] linked = LinkedEPSFStar(linked_members) for member in linked: member._excluded_from_fit = True stars = EPSFStars([*good_stars, linked]) builder = EPSFBuilder(oversampling=1, maxiters=3, progress_bar=False) with warnings.catch_warnings(record=True) as recorded: warnings.simplefilter('always') result = builder.build_epsf(stars) messages = [str(w.message) for w in recorded] assert not any('Cannot constrain centers' in msg for msg in messages) assert result.epsf is not None @pytest.mark.parametrize('value', ['bogus', 'AUTO']) def test_invalid_smoothing_kernel_at_init(value): match = "smoothing_kernel must be 'auto', 'quartic', 'quadratic'" with pytest.raises(ValueError, match=match): EPSFBuilder(smoothing_kernel=value) @pytest.mark.parametrize('value', ['bogus', 'AUTO']) def test_invalid_fit_shape_string(value): match = "fit_shape must be 'auto', an integer, a tuple of integers" with pytest.raises(ValueError, match=match): EPSFBuilder(fit_shape=value) @pytest.mark.parametrize(('value', 'error'), [(-3, ValueError), (0, ValueError), (2.5, TypeError), (True, TypeError), ('5', TypeError), (None, TypeError)]) def test_invalid_recentering_maxiters(value, error): match = 'recentering_maxiters must be a strictly-positive integer' with pytest.raises(error, match=match): EPSFBuilder(recentering_maxiters=value) def _make_heterogeneous_stars(seed=0, n_side=14): """ Make a star sample that mimics nonideal data. The stars have a range of FWHMs and ellipticities, a wide range of fluxes, initial center errors, and about 30 percent of the cutouts contain only noise. """ rng = np.random.default_rng(seed) offset = 32 size = n_side * offset + offset yy, xx = np.mgrid[0:size, 0:size] image = np.zeros((size, size)) xpos = [] ypos = [] for y0 in np.arange(offset, size - offset // 2, offset): for x0 in np.arange(offset, size - offset // 2, offset): x = x0 + rng.uniform(-0.5, 0.5) y = y0 + rng.uniform(-0.5, 0.5) if rng.uniform() > 0.3: sigma = rng.uniform(3.0, 6.0) / 2.3548 ellip = rng.uniform(0, 0.3) theta = rng.uniform(0, np.pi) amp = np.exp(rng.uniform(np.log(50), np.log(2000))) slc = (slice(int(y) - 14, int(y) + 15), slice(int(x) - 14, int(x) + 15)) model = Gaussian2D(amp, x, y, sigma * (1 + ellip), sigma * (1 - ellip), theta) image[slc] += model(xx[slc], yy[slc]) xpos.append(x) ypos.append(y) image += rng.normal(0, 5.0, image.shape) xpos = np.array(xpos) ypos = np.array(ypos) catalog = Table() catalog['x'] = xpos + rng.normal(0, 0.5, len(xpos)) catalog['y'] = ypos + rng.normal(0, 0.5, len(ypos)) return extract_stars(NDData(image), catalog, size=25) def _alias_power_fraction(data, oversampling): """ Fraction of the ePSF power near the star-pixel alias frequencies along each axis. """ power = np.abs(np.fft.fft2(data))**2 fractions = [] for axis in (0, 1): freq = np.abs(np.fft.fftfreq(data.shape[axis])) near = np.zeros(data.shape[axis], dtype=bool) for k in range(1, oversampling // 2 + 1): near |= np.abs(freq - k / oversampling) < 0.03 idx = [slice(None), slice(None)] idx[axis] = near fractions.append(power[tuple(idx)].sum() / power.sum()) return fractions @pytest.mark.parametrize('oversampling', [2, 4]) def test_build_heterogeneous_stars_no_checkerboard(oversampling): """ Regression test for a checkerboard ePSF built from nonideal stars with oversampling > 1. Star-to-star heterogeneity produces pixel-to-pixel noise in the oversampled ePSF grid, which biases the fitted star centers toward particular subpixel phases. The biased centers then feed alternate oversampled grid points with different subsets of stars, which grows a checkerboard (alias) pattern in the ePSF. """ stars = _make_heterogeneous_stars() builder = EPSFBuilder(oversampling=oversampling, maxiters=4, fit_shape=11, recentering_boxsize=11, progress_bar=False) with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) result = builder(stars) assert np.all(np.isfinite(result.epsf.data)) alias_power = _alias_power_fraction(result.epsf.data, oversampling) assert max(alias_power) < 5.0e-4 # The fitted subpixel phases must stay uniform centers = result.fitted_stars.cutout_center_flat for axis in (0, 1): hist = np.histogram(centers[:, axis] % 1, bins=4, range=(0, 1))[0] chi2 = np.sum((hist - hist.mean())**2) / hist.mean() assert chi2 < 20.0 def test_build_heterogeneous_stars_deposit_width(): """ Regression test for the width of the residual deposit. Depositing each star pixel only on the grid points within one grid spacing (or within 0.25 pixel) of its center gives a sharper ePSF for clean stars, but for this heterogeneous star sample it grows a checkerboard pattern within a few iterations at an oversampling factor of 6. The deposit must be wide enough to prevent that. """ oversampling = 6 stars = _make_heterogeneous_stars(seed=3) builder = EPSFBuilder(oversampling=oversampling, maxiters=8, fit_shape=11, recentering_boxsize=11, progress_bar=False) with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) result = builder(stars) assert np.all(np.isfinite(result.epsf.data)) alias_power = _alias_power_fraction(result.epsf.data, oversampling) assert max(alias_power) < 5.0e-4 assert result.epsf.data.min() > -0.05 * result.epsf.data.max() centers = result.fitted_stars.cutout_center_flat for axis in (0, 1): hist = np.histogram(centers[:, axis] % 1, bins=4, range=(0, 1))[0] chi2 = np.sum((hist - hist.mean())**2) / hist.mean() assert chi2 < 20.0 def test_resample_residual_pixel_footprint(): """ Each star pixel residual is deposited on every oversampled grid point inside the pixel footprint, so a single star covers both parities of the grid along each axis. """ data = _make_gaussian_star_data() star = EPSFStar(data, cutout_center=(5.25, 5.25)) stars = EPSFStars([star]) builder = EPSFBuilder(oversampling=2, progress_bar=False) epsf = builder._create_initial_epsf(stars) resid = builder._resample_residuals(stars, epsf)[0] finite = np.isfinite(resid) assert finite[:, 0::2].any() assert finite[:, 1::2].any() assert finite[0::2, :].any() assert finite[1::2, :].any() # Every finite grid value equals one of the normalized star pixel # values and each star pixel covers exactly 2x2 grid points. values = data.ravel() / star.flux assert np.all(np.isin(resid[finite], values)) assert finite.sum() == 4 * data.size # With oversampling=1 the deposit reduces to the nearest grid point. builder1 = EPSFBuilder(oversampling=1, progress_bar=False) epsf1 = builder1._create_initial_epsf(stars) resid1 = builder1._resample_residuals(stars, epsf1)[0] assert np.isfinite(resid1).sum() == data.size # With oversampling=4 each star pixel covers only the grid points # within 0.375 pixel (1.5 grid spacings) of its center (3x3 # points), not its whole footprint (4x4 points). builder4 = EPSFBuilder(oversampling=4, progress_bar=False) epsf4 = builder4._create_initial_epsf(stars) resid4 = builder4._resample_residuals(stars, epsf4)[0] finite4 = np.isfinite(resid4) assert finite4.sum() == 9 * data.size assert np.all(np.isin(resid4[finite4], values)) def test_suppress_alias_modes(): """ The alias low-pass removes a checkerboard, leaves a well-sampled PSF unchanged, and is a no-op for oversampling=1. """ yy, xx = np.mgrid[0:61, 0:61] sigma = 2 * 7.0 / 2.3548 psf = np.exp(-((xx - 30.0)**2 + (yy - 30.0)**2) / (2 * sigma**2)) checker = 0.1 * (-1.0)**(xx + yy) * psf result = _suppress_alias_modes(psf + checker, (2, 2)) assert_allclose(result, psf, atol=2e-3) assert_allclose(_suppress_alias_modes(psf, (2, 2)), psf, atol=1e-5) assert_array_equal(_suppress_alias_modes(psf + checker, (1, 1)), psf + checker) # Only the oversampled axis is filtered. A single-axis alternation # is suppressed by more than a factor of ten. rows = 0.1 * (-1.0)**yy * psf result = _suppress_alias_modes(psf + rows, (2, 1)) assert np.abs(result - psf).max() < 0.1 * np.abs(rows).max() result = _suppress_alias_modes(psf + rows, (1, 2)) assert_allclose(result, psf + rows, atol=1e-5) def test_suppress_alias_modes_default_passband(): """ The default passband ends at 0.8 cycles per input pixel, or at 0.7 cycles per input pixel for an oversampling factor of 2. """ npts = 64 xx = np.arange(npts) for oversampling, expected in ((2, 0.7), (3, 0.8), (4, 0.8)): # Frequencies (cycles per input pixel) on the FFT grid nu = np.fft.rfftfreq(npts) * oversampling below = nu[nu < expected - 1e-6][-1] above = nu[(nu > expected + 1e-6) & (nu < 1.0)][0] for freq, passed in ((below, True), (above, False)): wave = np.cos(2 * np.pi * freq / oversampling * xx) data = np.tile(wave, (npts, 1)) result = _suppress_alias_modes(data, (1, oversampling)) if passed: assert_allclose(result, data, atol=1e-10) else: assert np.abs(result).max() < 0.999 * np.abs(data).max() @pytest.mark.parametrize(('value', 'error'), [('bogus', ValueError), ('AUTO', ValueError), (0, ValueError), (1, ValueError), (1.5, ValueError), (-0.1, ValueError), (np.nan, ValueError), (True, TypeError), ((0.8, 0.8), TypeError)]) def test_invalid_alias_passband(value, error): match = "alias_passband must be 'auto', a number between 0 and 1" with pytest.raises(error, match=match): EPSFBuilder(alias_passband=value) @pytest.mark.parametrize(('alias_passband', 'expected'), [('auto', [(0.7, 0.7)]), (0.9, [(0.9, 0.9)]), (0.65, [(0.65, 0.65)]), (None, [])]) def test_alias_passband(monkeypatch, alias_passband, expected): """ The ``alias_passband`` keyword sets the passband of the alias low-pass filter, and `None` turns the filter off. """ from photutils.psf import epsf_builder calls = [] original = epsf_builder._suppress_alias_modes def recorder(data, oversampling, **kwargs): calls.append(kwargs.get('nu_pass')) return original(data, oversampling, **kwargs) monkeypatch.setattr(epsf_builder, '_suppress_alias_modes', recorder) data = _make_gaussian_star_data() stars = EPSFStars([EPSFStar(data, cutout_center=(5.25, 5.25))]) builder = EPSFBuilder(oversampling=2, alias_passband=alias_passband, smoothing_kernel=None, progress_bar=False) assert builder.alias_passband == alias_passband epsf = builder._build_epsf_step(stars) assert calls == expected assert np.all(np.isfinite(epsf.data)) def test_alias_passband_changes_epsf(): """ A narrow passband removes fine structure of the ePSF that a wide passband or no filter keeps. """ data = _make_gaussian_star_data() stars = EPSFStars([EPSFStar(data, cutout_center=(5.25, 5.25))]) peaks = [] for alias_passband in (0.3, 0.9, None): builder = EPSFBuilder(oversampling=2, alias_passband=alias_passband, smoothing_kernel=None, progress_bar=False) peaks.append(builder._build_epsf_step(stars).data.max()) assert peaks[0] < 0.99 * peaks[1] assert peaks[0] < 0.99 * peaks[2] @pytest.mark.parametrize(('value', 'error'), [(-1, ValueError), (2.5, TypeError), (True, TypeError), ('auto', TypeError), (None, TypeError)]) def test_invalid_refinement_iters(value, error): match = 'refinement_iters must be a nonnegative integer' with pytest.raises(error, match=match): EPSFBuilder(refinement_iters=value) @pytest.mark.parametrize('value', [1, 0, 'yes', None]) def test_invalid_constrain_fluxes(value): match = 'constrain_fluxes must be a bool' with pytest.raises(TypeError, match=match): EPSFBuilder(constrain_fluxes=value) def test_refinement_iters(epsf_test_data): """ The refinement iterations update the ePSF and refit the stars after the building iterations. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:40], size=11) results = [] for refinement_iters in (0, 2): builder = EPSFBuilder(oversampling=4, maxiters=3, refinement_iters=refinement_iters, progress_bar=False) assert builder.refinement_iters == refinement_iters results.append(builder(stars)) # The refinement is not counted in the building iterations assert results[0].iterations == results[1].iterations # The ePSF changes, but only slightly, and stays normalized data0 = results[0].epsf.data data1 = results[1].epsf.data assert np.all(np.isfinite(data1)) assert not np.allclose(data0, data1, rtol=0, atol=1e-8) assert_allclose(data1, data0, atol=0.02 * data0.max()) assert_allclose(data1.sum(), data0.sum()) # The stars are refit, and their centers barely move centers0 = results[0].fitted_stars.cutout_center_flat centers1 = results[1].fitted_stars.cutout_center_flat assert not np.array_equal(centers0, centers1) assert_allclose(centers1, centers0, atol=0.05) def test_refinement_steps(monkeypatch): """ In a refinement iteration the ePSF is updated five times with the refinement filter and recentered in each update, and the stars are then refit. The refinement is skipped without the alias filter and for an oversampling factor less than 4. """ from photutils.psf import epsf_builder refine_flags = [] original = epsf_builder._suppress_alias_modes def recorder(data, oversampling, **kwargs): # Only the refinement filter has a stopband above one cycle # per input pixel refine_flags.append(max(kwargs['nu_stop']) > 1.0) return original(data, oversampling, **kwargs) monkeypatch.setattr(epsf_builder, '_suppress_alias_modes', recorder) data = _make_gaussian_star_data() stars = EPSFStars([EPSFStar(data, cutout_center=(5.25, 5.25))]) counts = {} def run(**kwargs): builder = EPSFBuilder(maxiters=1, smoothing_kernel=None, progress_bar=False, **kwargs) find_center = builder._find_epsf_center fit_stars = builder._fit_stars counts.update(recenter=0, fit=0) def counting_find_center(epsf, **kw): # The center asymmetry is measured with the same method # and an explicit centroid function if 'centroid_func' not in kw: counts['recenter'] += 1 return find_center(epsf, **kw) def counting_fit(epsf, stars_): counts['fit'] += 1 return fit_stars(epsf, stars_) monkeypatch.setattr(builder, '_find_epsf_center', counting_find_center) monkeypatch.setattr(builder, '_fit_stars', counting_fit) refine_flags.clear() with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) builder(stars) return (refine_flags.count(False), refine_flags.count(True), counts['recenter'], counts['fit']) # (build updates, refinement updates, recenterings, star fits) for # one building iteration and the given refinement iterations assert run(oversampling=4, refinement_iters=2) == (1, 10, 11, 3) assert run(oversampling=4, refinement_iters=1, alias_passband=0.6) == (1, 5, 6, 2) assert run(oversampling=4, refinement_iters=0) == (1, 0, 1, 1) assert run(oversampling=(2, 4), refinement_iters=1) == (1, 5, 6, 2) # No refinement without the filter or for an oversampling factor # less than 4 assert run(oversampling=4, refinement_iters=2, alias_passband=None) == (0, 0, 1, 1) assert run(oversampling=2, refinement_iters=2) == (1, 0, 1, 1) assert run(oversampling=1, refinement_iters=2) == (1, 0, 1, 1) def test_alias_filter_band(): """ The refinement filter has unit gain up to 1.1 cycles per input pixel and zero gain at and above 1.33 cycles per input pixel, but only along axes with an oversampling factor of at least 4. """ assert _alias_filter_band(2) == (0.7, 1.0) assert _alias_filter_band(3) == (0.8, 1.0) assert _alias_filter_band(4) == (0.8, 1.0) assert _alias_filter_band(4, nu_pass=0.6) == (0.6, 1.0) for oversampling in (4, 8): # The input passband does not change the refinement filter for nu_pass in (None, 0.6): band = _alias_filter_band(oversampling, nu_pass=nu_pass, refine=True) assert_allclose(band, (1.1, 1 / 0.75)) # No change for an oversampling factor less than 4 assert _alias_filter_band(2, refine=True) == (0.7, 1.0) assert _alias_filter_band(3, nu_pass=0.6, refine=True) == (0.6, 1.0) npts = 80 xx = np.arange(npts) def gain(oversampling, freq, *, axis=1, refine=False): wave = np.cos(2 * np.pi * freq / oversampling * xx) data = np.tile(wave, (npts, 1)) factors = [1, 1] factors[axis] = oversampling if axis == 0: data = data.T bands = [_alias_filter_band(factor, refine=refine) for factor in factors] result = _suppress_alias_modes(data, tuple(factors), nu_pass=(bands[0][0], bands[1][0]), nu_stop=(bands[0][1], bands[1][1])) return np.abs(result).max() / np.abs(data).max() # These frequencies are on the FFT grid for these sizes for axis in (0, 1): assert gain(4, 0.9, axis=axis) < 0.6 assert gain(4, 1.0, axis=axis) < 1e-10 for freq in (0.9, 1.0, 1.1): for oversampling in (4, 8): assert_allclose(gain(oversampling, freq, axis=axis, refine=True), 1.0, atol=1e-10) assert 0.1 < gain(4, 1.2, axis=axis, refine=True) < 0.9 assert gain(4, 1.35, axis=axis, refine=True) < 1e-10 assert gain(4, 1.5, axis=axis, refine=True) < 1e-10 assert gain(2, 0.9, axis=axis, refine=True) < 0.6 assert_allclose(gain(2, 0.9, axis=axis), gain(2, 0.9, axis=axis, refine=True)) def test_suppress_alias_modes_axis_bands(): """ The passband and stopband can differ between the two axes. """ npts = 80 wave = np.cos(2 * np.pi * 1.0 / 4 * np.arange(npts)) data = np.tile(wave, (npts, 1)) kwargs = {'nu_pass': (0.8, 1.1), 'nu_stop': (1.0, 1 / 0.75)} assert_allclose(_suppress_alias_modes(data, (4, 4), **kwargs), data, atol=1e-10) result = _suppress_alias_modes(data.T, (4, 4), **kwargs) assert np.abs(result).max() < 1e-10 def test_refinement_convergence(epsf_test_data): """ The ePSF is recentered in the refinement updates, so the ePSF and the star centers do not keep a common offset, and the convergence diagnostics describe the last refinement iteration. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:40], size=11) # The input centers are the true centers true_centers = stars.cutout_center_flat.copy() results = [] for refinement_iters in (0, 4): builder = EPSFBuilder(oversampling=4, maxiters=5, refinement_iters=refinement_iters, progress_bar=False) results.append(builder(stars)) build, refined = results assert build.iterations == refined.iterations == 5 assert not build.converged assert refined.converged assert refined.final_converged_fraction > build.final_converged_fraction assert refined.final_center_accuracy < 0.2 * build.final_center_accuracy assert refined.final_center_accuracy < 1.0e-3 offset = refined.fitted_stars.cutout_center_flat - true_centers assert np.all(np.abs(offset.mean(axis=0)) < 1.0e-4) def test_refinement_excludes_failed_fits(epsf_test_data): """ A star whose fit fails in a refinement iteration is excluded with a warning, as in the building iterations. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:10], size=11) n_build_fits = 4 * len(stars) class RefinementFailingFitter(TRFLSQFitter): """ Fail the first fit of the refinement iterations. """ def __init__(self): super().__init__() self.call_count = 0 def __call__(self, *args, **kwargs): result = super().__call__(*args, **kwargs) if self.call_count == n_build_fits: self.fit_info['ierr'] = 0 self.call_count += 1 return result builder = EPSFBuilder(oversampling=4, maxiters=4, refinement_iters=2, center_accuracy=1.0e-8, fitter=RefinementFailingFitter(), progress_bar=False) match = 'has been excluded from ePSF fitting' with pytest.warns(AstropyUserWarning, match=match) as record: result = builder(stars) assert len([rec for rec in record if match in str(rec.message)]) == 1 assert result.iterations == 4 assert result.excluded_star_indices == [0] assert result.n_excluded_stars == 1 def test_refinement_progress_bar(epsf_test_data, monkeypatch): """ The refinement iterations have their own progress bar, which starts after the progress bar of the building iterations is finished. """ from photutils.psf import epsf_builder bars = [] class Bar: def __init__(self, total, desc): self.total = total self.desc = desc self.count = 0 self.closed = False self.messages = [] def update(self): self.count += 1 def write(self, message): assert not self.closed self.messages.append(message) def close(self): self.closed = True def add_progress_bar(*, total, desc): # The earlier progress bar is finished before the next starts assert all(bar.closed for bar in bars) bars.append(Bar(total, desc)) return bars[-1] monkeypatch.setattr(epsf_builder, 'add_progress_bar', add_progress_bar) stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:10], size=11) EPSFBuilder(oversampling=4, maxiters=2, refinement_iters=3)(stars) assert [(bar.desc, bar.total, bar.count) for bar in bars] == [ ('EPSFBuilder (2 maxiters)', 2, 2), ('EPSFBuilder refinement', 3, 3)] assert all(bar.closed for bar in bars) assert all(not bar.messages for bar in bars) # The convergence message is about the building iterations bars.clear() EPSFBuilder(oversampling=4, maxiters=10, refinement_iters=1, center_accuracy=0.1)(stars) assert len(bars[0].messages) == 1 assert bars[0].messages[0].startswith( 'EPSFBuilder building iterations converged after') assert not bars[1].messages # No refinement bar without refinement iterations bars.clear() EPSFBuilder(oversampling=2, maxiters=2, refinement_iters=3)(stars) EPSFBuilder(oversampling=4, maxiters=2, refinement_iters=0)(stars) assert [bar.desc for bar in bars] == ['EPSFBuilder (2 maxiters)'] * 2 class TestIterationHistory: """ Tests for the per-iteration ePSFs of `EPSFBuildResults`. """ @pytest.fixture(scope='class') @classmethod def stars(cls): psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) data, params = make_psf_model_image( (400, 400), psf_model, 30, model_shape=(9, 9), flux=(500, 700), min_separation=25, border_size=25, seed=0) tbl = Table({'x': params['x_0'], 'y': params['y_0']}) return extract_stars(NDData(data), tbl, size=11) def test_history(self, stars): builder = EPSFBuilder(oversampling=4, maxiters=3, refinement_iters=2, progress_bar=False) result = builder(stars) n_total = result.iterations + 2 assert len(result.iteration_epsfs) == n_total assert len(result.iteration_info) == n_total for data in result.iteration_epsfs: assert data.shape == result.epsf.data.shape assert np.all(np.isfinite(data)) assert_array_equal(result.iteration_epsfs[-1], result.epsf.data) # The two centers of a symmetric ePSF agree assert_allclose(result.center_asymmetry, 0, atol=2.0e-3) # The images are copies, not views of one array assert (result.iteration_epsfs[0] is not result.iteration_epsfs[1]) assert not np.array_equal(result.iteration_epsfs[0], result.iteration_epsfs[1]) info = result.iteration_info assert info.colnames == ['iteration', 'stage', 'converged', 'converged_fraction', 'max_center_shift', 'common_center_shift', 'n_fit_failed', 'max_epsf_change'] assert info['converged'].dtype == bool assert_array_equal(info['converged'], info['converged_fraction'] >= 0.95) assert_array_equal(info['iteration'], np.arange(1, n_total + 1)) assert list(info['stage']) == (['build'] * result.iterations + ['refine'] * 2) assert np.all((info['converged_fraction'] >= 0) & (info['converged_fraction'] <= 1)) assert np.all(info['max_center_shift'] >= 0) assert np.all(info['common_center_shift'] >= 0) assert np.all(info['n_fit_failed'] == 0) # The first iteration starts from an empty ePSF, and the ePSF # changes less in the later iterations assert info['max_epsf_change'][0] > 0.5 assert info['max_epsf_change'][-1] < info['max_epsf_change'][0] # The last iteration, which is a refinement iteration, matches # the reported convergence assert info['converged'][-1] == result.converged assert_allclose(info['converged_fraction'][-1], result.final_converged_fraction) assert_allclose(info['max_center_shift'][-1], result.final_center_accuracy) def test_history_build_convergence(self, stars): """ The table tells whether the building iterations converged when the refinement changes the reported convergence. """ kwargs = {'oversampling': 4, 'maxiters': 5, 'progress_bar': False} build = EPSFBuilder(refinement_iters=0, **kwargs)(stars) refined = EPSFBuilder(refinement_iters=4, **kwargs)(stars) assert not build.converged assert refined.converged info = refined.iteration_info is_build = info['stage'] == 'build' assert not info['converged'][is_build][-1] assert info['converged'][-1] for name in ('converged', 'converged_fraction', 'max_center_shift'): assert_allclose(info[name][is_build], build.iteration_info[name]) def test_history_initial_epsf(self, stars): """ The change of the first iteration is measured from the input ePSF when the build starts from one. """ builder = EPSFBuilder(oversampling=4, maxiters=3, refinement_iters=0, progress_bar=False) first = builder(stars) assert first.initial_epsf is None assert first.iteration_info['max_epsf_change'][0] > 0.5 initial = first.epsf.data.copy() result = builder.build_epsf(stars, epsf=first.epsf) assert_array_equal(result.initial_epsf, initial) assert not np.shares_memory(result.initial_epsf, first.epsf.data) change = (np.max(np.abs(result.iteration_epsfs[0] - initial)) / result.epsf.data.max()) assert 0 < change < 0.01 assert_allclose(result.iteration_info['max_epsf_change'][0], change) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_iterations_initial_epsf(self, stars): import matplotlib.pyplot as plt builder = EPSFBuilder(oversampling=4, maxiters=2, refinement_iters=0, progress_bar=False) first = builder(stars) result = builder.build_epsf(stars, epsf=first.epsf) change = result.iteration_info['max_epsf_change'][0] # The first difference panel is the change from the input ePSF fig = result.plot_iterations(iterations=1) assert_allclose(fig.axes[1].get_images()[0].get_clim(), (-change, change)) plt.close(fig) # Without an input ePSF it is the ePSF itself fig = first.plot_iterations(iterations=1) assert fig.axes[1].get_images()[0].get_clim()[1] > 0.5 plt.close(fig) def test_history_table_format(self, stars): builder = EPSFBuilder(oversampling=2, maxiters=2, progress_bar=False) info = builder(stars).iteration_info lines = info.pformat() assert len(lines[-1].split()) == len(info.colnames) fraction = lines[-1].split()[info.colnames.index( 'converged_fraction')] assert len(fraction) == 5 def test_history_without_refinement(self, stars): builder = EPSFBuilder(oversampling=2, maxiters=2, progress_bar=False) result = builder(stars) assert len(result.iteration_epsfs) == result.iterations assert set(result.iteration_info['stage']) == {'build'} assert_array_equal(result.iteration_epsfs[-1], result.epsf.data) def test_history_not_in_tuple_or_repr(self, stars): builder = EPSFBuilder(oversampling=2, maxiters=2, progress_bar=False) result = builder(stars) assert len(result) == 2 epsf, fitted_stars = result assert epsf is result.epsf assert fitted_stars is result.fitted_stars assert 'iteration_epsfs' not in repr(result) assert 'iteration_info' not in repr(result) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_iterations(self, stars): import matplotlib.pyplot as plt builder = EPSFBuilder(oversampling=4, maxiters=2, refinement_iters=1, progress_bar=False) result = builder(stars) n_total = len(result.iteration_epsfs) fig = result.plot_iterations() # two image panels and two colorbars per iteration assert len(fig.axes) == 4 * n_total titles = [ax.get_title() for ax in fig.axes] assert 'Iteration 1 (build)' in titles assert f'Iteration {n_total} (refine)' in titles plt.close(fig) fig = result.plot_iterations(iterations=[1, n_total], figsize=(6, 5), cmap='gray') assert len(fig.axes) == 8 assert tuple(fig.get_size_inches()) == (6.0, 5.0) plt.close(fig) fig = result.plot_iterations(iterations=np.array([1, 2])) assert_allclose(fig.get_size_inches(), (7.0, 5.2)) plt.close(fig) fig = result.plot_iterations(iterations=2) assert len(fig.axes) == 4 plt.close(fig) # An ePSF that did not change from the previous iteration result.iteration_epsfs[1] = result.iteration_epsfs[0] fig = result.plot_iterations(iterations=2) assert fig.axes[1].get_images()[0].get_clim() == (-1.0, 1.0) plt.close(fig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_iterations_invalid(self, stars): builder = EPSFBuilder(oversampling=2, maxiters=2, progress_bar=False) result = builder(stars) match = 'iterations must be between 1 and' for value in (0, [1, 99]): with pytest.raises(ValueError, match=match): result.plot_iterations(iterations=value) match = 'iterations must be an integer or a nonempty 1D array' for value in ([], 1.5, [1.0, 2.0], True, [[1, 2]], 'all'): with pytest.raises(ValueError, match=match): result.plot_iterations(iterations=value) match = 'There is no per-iteration history to plot' iteration_info = result.iteration_info result.iteration_info = None with pytest.raises(ValueError, match=match): result.plot_iterations() result.iteration_info = iteration_info result.iteration_epsfs = None with pytest.raises(ValueError, match=match): result.plot_iterations() @pytest.mark.parametrize('value', [1, 0, 'yes', None]) def test_invalid_wing_smoothing(value): match = 'wing_smoothing must be a bool' with pytest.raises(TypeError, match=match): EPSFBuilder(wing_smoothing=value) def _noisy_gaussian_epsf(fwhm=6.0, size=101, noise=0.002, seed=0): """ A Gaussian ePSF image with a FWHM of ``fwhm`` grid points, with and without white noise. """ yy, xx = np.indices((size, size), dtype=float) center = (size - 1) / 2 sigma = fwhm / 2.3548 model = np.exp(-((xx - center)**2 + (yy - center)**2) / (2 * sigma**2)) # add a slowly varying wing so that the wings are not zero radius = np.hypot(xx - center, yy - center) model += 0.01 / (1 + (radius / 20.0)**2) rng = np.random.default_rng(seed) return model, model + rng.normal(0, noise, model.shape), radius def test_smooth_wings(): """ The wing smoothing leaves the ePSF within 3.5 FWHM of its center unchanged and lowers the noise beyond that without biasing the wings. """ fwhm = 6.0 model, noisy, radius = _noisy_gaussian_epsf(fwhm=fwhm) builder = EPSFBuilder(oversampling=4, progress_bar=False) result = builder._smooth_wings(noisy) assert result.shape == noisy.shape core = radius <= 3.4 * fwhm assert_array_equal(result[core], noisy[core]) for lo, hi, factor in ((4.6, 5.0, 2.0), (6.2, 8.0, 3.0)): wings = (radius >= lo * fwhm) & (radius <= hi * fwhm) rms_before = np.std((noisy - model)[wings]) rms_after = np.std((result - model)[wings]) assert rms_after < rms_before / factor # no bias from the curvature of the wing profile assert abs(np.mean((result - model)[wings])) < 0.1 * rms_before # A noiseless ePSF is reproduced, to within the small effect of the # image edges on the fits smooth = builder._smooth_wings(model) assert_allclose(smooth, model, atol=5e-5) def test_smooth_wings_structure(): """ The wing smoothing keeps structure in the wings that varies over several FWHM and removes structure that varies over about one FWHM, such as narrow diffraction rings. """ fwhm = 6.0 size = 101 yy, xx = np.indices((size, size), dtype=float) center = (size - 1) / 2 radius = np.hypot(xx - center, yy - center) core = np.exp(-radius**2 / (2 * (fwhm / 2.3548)**2)) envelope = 0.01 / (1 + (radius / 20.0)**2) builder = EPSFBuilder(oversampling=4, progress_bar=False) def wing_change(period): """ The RMS change of a ringed wing as a fraction of its mean. """ rings = 1 + 0.5 * np.cos(2 * np.pi * radius / (period * fwhm)) model = core + envelope * rings result = builder._smooth_wings(model) assert_array_equal(result[radius <= 3.4 * fwhm], model[radius <= 3.4 * fwhm]) # the sum, and so the normalization, is nearly unchanged assert_allclose(result.sum(), model.sum(), rtol=1e-3) wings = (radius >= 6.2 * fwhm) & (radius <= 8.0 * fwhm) return (np.sqrt(np.mean((result - model)[wings]**2)) / np.mean(model[wings])) assert wing_change(4.0) < 0.02 assert 0.05 < wing_change(2.0) < 0.2 assert wing_change(1.0) > 0.3 def test_smooth_wings_anisotropic_oversampling(): """ With different oversampling factors along the two axes, the radii of the wing smoothing are measured in detector pixels. """ fwhm = 1.5 # pixels ny, nx = 61, 121 yy, xx = np.indices((ny, nx), dtype=float) radius = np.hypot((xx - (nx - 1) / 2) / 4, (yy - (ny - 1) / 2) / 2) model = (np.exp(-radius**2 / (2 * (fwhm / 2.3548)**2)) + 0.01 / (1 + (radius / 5.0)**2)) rng = np.random.default_rng(0) noisy = model + rng.normal(0, 0.002, model.shape) builder = EPSFBuilder(oversampling=(2, 4), progress_bar=False) result = builder._smooth_wings(noisy) core = radius <= 3.4 * fwhm assert_array_equal(result[core], noisy[core]) for lo, hi in ((4.6, 5.0), (6.2, 8.0)): wings = (radius >= lo * fwhm) & (radius <= hi * fwhm) rms_before = np.std((noisy - model)[wings]) rms_after = np.std((result - model)[wings]) assert rms_after < rms_before / 2.0 assert abs(np.mean((result - model)[wings])) < 0.15 * rms_before def test_smooth_wings_no_change(): """ The ePSF is returned unchanged if its FWHM cannot be measured, if the whole image is within 3.5 FWHM of the center, or if it has nonfinite values. """ data = _noisy_gaussian_epsf(fwhm=6.0)[1] builder = EPSFBuilder(oversampling=4, progress_bar=False) assert builder._smooth_wings(data) is not data for value in (np.nan, np.inf): bad = data.copy() bad[90, 90] = value assert builder._smooth_wings(bad) is bad builder = EPSFBuilder(oversampling=1, progress_bar=False) data = np.zeros((25, 25)) assert builder._smooth_wings(data) is data data = _noisy_gaussian_epsf(fwhm=8.0, size=25)[1] assert builder._smooth_wings(data) is data def test_wing_smoothing_build(epsf_test_data): """ The wing smoothing is applied once to the final ePSF. It does not change the fitted stars or the core of the ePSF. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:40], size=25) results = [] for wing_smoothing in (False, True): builder = EPSFBuilder(oversampling=2, maxiters=3, wing_smoothing=wing_smoothing, progress_bar=False) assert builder.wing_smoothing is wing_smoothing results.append(builder(stars)) assert_array_equal(results[0].fitted_stars.cutout_center_flat, results[1].fitted_stars.cutout_center_flat) assert results[0].iterations == results[1].iterations data0 = results[0].epsf.data data1 = results[1].epsf.data assert not np.array_equal(data0, data1) assert_allclose(data1.sum(), data0.sum()) # The ePSF FWHM is about 2.7 pixels, so the ePSF within 9 pixels # of the center changes only by the renormalization ny, nx = data0.shape yy, xx = np.indices((ny, nx)) radius = np.hypot(xx - (nx - 1) / 2, yy - (ny - 1) / 2) / 2.0 core = radius < 9.0 assert_allclose(data1[core], data0[core], rtol=1e-3) assert np.std(data1[~core]) < np.std(data0[~core]) # The wing smoothing is not an iteration. The last per-iteration # ePSF is the ePSF before its wings were smoothed. assert len(results[1].iteration_epsfs) == len(results[0].iteration_epsfs) assert_array_equal(results[1].iteration_epsfs[-1], data0) assert_array_equal(results[0].iteration_epsfs[-1], data0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_wing_smoothing_plot_iterations(epsf_test_data): """ The figure has a last row for the smoothed ePSF only if the wing smoothing changed the ePSF. By default that row is plotted only if all the iterations are plotted, and ``show_smoothed`` overrides that choice. """ import matplotlib.pyplot as plt stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:40], size=25) final_title = 'Final ePSF (wings smoothed)' for wing_smoothing in (False, True): builder = EPSFBuilder(oversampling=2, maxiters=3, wing_smoothing=wing_smoothing, progress_bar=False) result = builder(stars) n_total = len(result.iteration_epsfs) n_extra = int(wing_smoothing) fig = result.plot_iterations() # two image panels and two colorbars per row assert len(fig.axes) == 4 * (n_total + n_extra) assert_allclose(fig.get_size_inches(), (7.0, 2.6 * (n_total + n_extra))) titles = [ax.get_title() for ax in fig.axes] assert titles.count(final_title) == n_extra plt.close(fig) # All the iterations, selected explicitly fig = result.plot_iterations(iterations=np.arange(1, n_total + 1)) assert len(fig.axes) == 4 * (n_total + n_extra) plt.close(fig) fig = result.plot_iterations(show_smoothed=False) assert len(fig.axes) == 4 * n_total titles = [ax.get_title() for ax in fig.axes] assert final_title not in titles plt.close(fig) # A subset of the iterations does not show the smoothed ePSF fig = result.plot_iterations(iterations=1) assert len(fig.axes) == 4 titles = [ax.get_title() for ax in fig.axes] assert final_title not in titles plt.close(fig) fig = result.plot_iterations(iterations=1, show_smoothed=True) assert len(fig.axes) == 4 * (1 + n_extra) if wing_smoothing: # The last row shows the returned ePSF and the change made # by the smoothing. The image panels of the two rows come # before the colorbars in the list of axes. ax_epsf, ax_diff = fig.axes[2], fig.axes[3] assert ax_epsf.get_title() == final_title assert_array_equal(ax_epsf.get_images()[0].get_array(), result.epsf.data) last = result.iteration_epsfs[-1] diff = (result.epsf.data - last) / np.max(last) assert_allclose(ax_diff.get_images()[0].get_array(), diff) assert 'converged fraction' not in ax_diff.get_title() plt.close(fig) def test_nonuniform_phase_warning(): """ A warning is emitted when the fitted star centers have strongly nonuniform subpixel phases. """ data = _make_gaussian_star_data() # All stars at integer pixel phase and a fitter that does not move # the centers. stars = EPSFStars([EPSFStar(data, cutout_center=(5.0, 5.0)) for _ in range(60)]) def fitter(model, **_kwargs): return model builder = EPSFBuilder(oversampling=2, maxiters=1, fitter=fitter, progress_bar=False) match = 'subpixel phases of the fitted star centers' with pytest.warns(AstropyUserWarning, match=match): builder(stars) class TestPolynomialKernel: """ Tests for _SmoothingKernel.make_polynomial_kernel. """ def test_quartic_matches_anderson_king(self): """ The 'quartic' kernel is the 5x5 least-squares quartic kernel of equation 8 of Anderson and King 2000 (PASP 112, 1360). The printed table contains two misprints in its first and last rows that break the symmetry of the kernel, so the symmetric values are used here. """ published = 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]]) assert_allclose(_SmoothingKernel.QUARTIC_KERNEL, published, atol=1e-6) assert_allclose(_SmoothingKernel.get_kernel('quartic'), published, atol=1e-6) def test_exact_values(self): """ The 5x5 kernels have exact rational values. """ quartic = _SmoothingKernel.QUARTIC_KERNEL assert_allclose(quartic[2, 2], 541 / 1225, rtol=1e-12) assert_allclose(quartic[2, 1], 246 / 1225, rtol=1e-12) assert_allclose(quartic[2, 0], 96 / 1225, rtol=1e-12) assert_allclose(quartic[0, 0], 51 / 1225, rtol=1e-12) assert_allclose(quartic.sum(), 1.0, rtol=1e-12) quadratic = _SmoothingKernel.QUADRATIC_KERNEL assert_allclose(quadratic[2, 2], 27 / 175, rtol=1e-12) assert_allclose(quadratic[2, 1], 22 / 175, rtol=1e-12) assert_allclose(quadratic[2, 0], 1 / 25, rtol=1e-12) assert_allclose(quadratic[0, 0], -13 / 175, rtol=1e-12) assert_allclose(quadratic.sum(), 1.0, rtol=1e-12) @pytest.mark.parametrize('size', [7, 9, 11]) def test_properties(self, size): kernel = _SmoothingKernel.make_polynomial_kernel(size) assert kernel.shape == (size, size) assert_allclose(kernel.sum(), 1.0) assert_allclose(kernel, kernel.T) assert_allclose(kernel, kernel[::-1, ::-1]) # The kernel preserves any quartic polynomial exactly. yy, xx = np.mgrid[0:41, 0:41] - 20.0 poly = 1.0 + 0.1 * xx - 0.2 * yy + 0.01 * xx * yy**3 + 1e-3 * xx**4 smoothed = _SmoothingKernel.apply_smoothing(poly, kernel) half = size // 2 assert_allclose(smoothed[half:-half, half:-half], poly[half:-half, half:-half], atol=1e-9) def test_invalid(self): match = 'size must be an odd integer' with pytest.raises(ValueError, match=match): _SmoothingKernel.make_polynomial_kernel(4) with pytest.raises(ValueError, match=match): _SmoothingKernel.make_polynomial_kernel(1) match = 'degree must be at least 1' with pytest.raises(ValueError, match=match): _SmoothingKernel.make_polynomial_kernel(5, degree=0) with pytest.raises(ValueError, match=match): _SmoothingKernel.make_polynomial_kernel(3, degree=4) class TestMeasureFWHM: """ Tests for _measure_fwhm. """ @staticmethod def _gaussian(fwhm_y, fwhm_x, size=61): yy, xx = np.mgrid[0:size, 0:size] - size // 2 sig_y = fwhm_y / 2.3548 sig_x = fwhm_x / 2.3548 return np.exp(-0.5 * ((xx / sig_x)**2 + (yy / sig_y)**2)) def test_gaussian(self): fwhm_y, fwhm_x = _measure_fwhm(self._gaussian(6.0, 10.0)) assert_allclose(fwhm_y, 6.0, rtol=0.02) assert_allclose(fwhm_x, 10.0, rtol=0.02) def test_scaled_and_offset_peak(self): data = 1000.0 * self._gaussian(8.0, 8.0) data = np.roll(data, (5, -7), axis=(0, 1)) fwhm = _measure_fwhm(data) assert_allclose(fwhm, (8.0, 8.0), rtol=0.02) def test_unmeasurable(self): # Nonpositive peak assert _measure_fwhm(np.zeros((11, 11))) is None assert _measure_fwhm(-self._gaussian(5.0, 5.0)) is None # No finite values assert _measure_fwhm(np.full((11, 11), np.nan)) is None # Flat profile that never falls below half of the peak assert _measure_fwhm(np.ones((11, 11))) is None # Peak at the edge of the array data = self._gaussian(5.0, 5.0)[30:, :] assert _measure_fwhm(data) is None # Nonfinite value next to the peak data = self._gaussian(5.0, 5.0) data[30, 31] = np.nan assert _measure_fwhm(data) is None # Nonfinite row next to the peak, so the averaged row profile # has no finite values data = self._gaussian(5.0, 5.0) data[29, :] = np.nan assert _measure_fwhm(data) is None # Negative row next to the peak, so the averaged row profile # has a nonpositive peak data = np.zeros((11, 11)) data[5, 5] = 1.0 data[4, :] = -3.0 assert _measure_fwhm(data) is None # Infinite peak data = self._gaussian(5.0, 5.0) data[30, 30] = np.inf assert _measure_fwhm(data) is None def test_low_pixel_next_to_peak(self): """ The profiles are averaged over the three rows and columns around the peak, so a single low pixel next to the peak does not truncate the width. """ data = self._gaussian(11.2, 11.2) data[30, 33] = 0.1 fwhm = _measure_fwhm(data) assert_allclose(fwhm, (11.2, 11.2), rtol=0.05) class TestAutoSmoothingAndFitShape: """ Tests for the 'auto' smoothing kernel and fit shape. """ @pytest.fixture def stars(self, epsf_test_data): return extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:30], size=11) def test_auto_choices_oversampled(self, stars): """ The ePSF FWHM is about 2.9 pixels (11.2 grid points at oversampling 4), so the kernel is 0.7 x 11.2 = 7.9 -> 7 grid points (rounded down to an odd size) and the fit shape is 2 x 2.9 = 5.7 -> 7 pixels. """ builder = EPSFBuilder(oversampling=4, maxiters=3, progress_bar=False) result = builder(stars) assert result.smoothing_kernel.shape == (7, 7) assert result.fit_shape == (7, 7) fwhm = _measure_fwhm(result.epsf.data) assert_allclose(fwhm, (11.2, 11.2), rtol=0.05) # The reported kernel and fit shape reproduce the build when # input as fixed values. builder = EPSFBuilder(oversampling=4, maxiters=3, smoothing_kernel=result.smoothing_kernel, fit_shape=result.fit_shape, progress_bar=False) fixed = builder(stars) assert_allclose(fixed.epsf.data, result.epsf.data) assert_array_equal(fixed.smoothing_kernel, result.smoothing_kernel) def test_auto_no_smoothing_undersampled(self, stars): """ At oversampling 1 the window (0.7 x 2.9 = 2 grid points) is below the minimum kernel size, so no smoothing is applied. """ builder = EPSFBuilder(oversampling=1, maxiters=3, progress_bar=False) result = builder(stars) assert result.smoothing_kernel is None assert result.fit_shape == (7, 7) def test_auto_anisotropic_oversampling(self, stars): """ The FWHM along the narrowest axis in input pixels sets the fit shape and the narrowest axis in grid points sets the kernel. """ builder = EPSFBuilder(oversampling=(1, 2), maxiters=3, progress_bar=False) result = builder(stars) assert result.smoothing_kernel is None assert result.fit_shape == (7, 7) def test_auto_fit_shape_capped_by_cutout(self): """ A wide PSF in small cutouts: the 2 FWHM fit box (15 pixels) is capped at the cutout size and the kernel is 0.7 x 7.5 = 5.2 -> 5. """ fwhm = 7.5 data, params = make_psf_model_image( (600, 600), CircularGaussianPRF(flux=1, fwhm=fwhm), 30, model_shape=(25, 25), flux=(500, 700), min_separation=40, border_size=30, seed=1) tbl = Table({'x': params['x_0'], 'y': params['y_0']}) stars = extract_stars(NDData(data), tbl, size=11) builder = EPSFBuilder(oversampling=1, maxiters=3, progress_bar=False) result = builder(stars) assert result.smoothing_kernel.shape == (5, 5) assert result.fit_shape == (11, 11) def test_auto_fit_shape_capped_by_even_cutout(self): """ The automatic fit shape is capped at the largest odd size that fits in an even-sized cutout. """ yy, xx = np.indices((10, 10)) sig = 7.0 / 2.3548 data = np.exp(-((xx - 4.5)**2 + (yy - 4.5)**2) / (2 * sig**2)) stars = EPSFStars([EPSFStar(data, cutout_center=(4.5, 4.5)) for _ in range(3)]) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) result = builder(stars) assert result.fit_shape == (9, 9) @pytest.mark.parametrize( ('kwargs', 'match', 'kernel_shape', 'fit_shape'), [({}, ("the 'auto' smoothing_kernel and fit_shape fall back to " "the 'quartic' kernel and a fit shape of 5"), (5, 5), (5, 5)), ({'smoothing_kernel': None}, "the 'auto' fit_shape falls back to a fit shape of 5", None, (5, 5)), ({'fit_shape': 7}, "the 'auto' smoothing_kernel falls back to the 'quartic' kernel", (5, 5), (7, 7))]) def test_auto_fallback_warning(self, kwargs, match, kernel_shape, fit_shape): """ The FWHM of flat cutouts cannot be measured, so the build falls back to the quartic kernel and a 5x5 fit shape with a single warning that names only the 'auto' parameters. """ stars = EPSFStars([EPSFStar(np.ones((11, 11))) for _ in range(3)]) builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False, **kwargs) with pytest.warns(AstropyUserWarning, match=match) as record: result = builder(stars) messages = [str(r.message) for r in record if 'could not be measured' in str(r.message)] assert len(messages) == 1 assert messages[0].endswith(f'{match}.') if kernel_shape is None: assert result.smoothing_kernel is None else: assert result.smoothing_kernel.shape == kernel_shape assert result.fit_shape == fit_shape def test_fixed_choices_reported(self, stars): """ The results report fixed kernels and fit shapes too. """ builder = EPSFBuilder(oversampling=1, maxiters=1, smoothing_kernel='quadratic', fit_shape=None, progress_bar=False) result = builder(stars) assert_array_equal(result.smoothing_kernel, _SmoothingKernel.QUADRATIC_KERNEL) assert result.fit_shape is None # The results own a copy of the kernel, so editing it does not # change the shared predefined kernel result.smoothing_kernel[0, 0] = 0.0 assert _SmoothingKernel.QUADRATIC_KERNEL[0, 0] != 0.0 kernel = np.full((3, 3), 1 / 9) builder = EPSFBuilder(oversampling=1, maxiters=1, smoothing_kernel=kernel, fit_shape=(5, 7), progress_bar=False) result = builder(stars) assert result.smoothing_kernel is not kernel assert_array_equal(result.smoothing_kernel, kernel) assert result.fit_shape == (5, 7) builder = EPSFBuilder(oversampling=1, maxiters=1, smoothing_kernel=None, progress_bar=False) result = builder(stars) assert result.smoothing_kernel is None class _ShiftingFitter(TRFLSQFitter): """ A fitter that alternately shifts the fitted x center of the first ``n_shift`` stars of every ``n_stars`` fits by half a pixel, so that the centers of those stars never settle. """ def __init__(self, n_stars, n_shift): super().__init__() self.n_stars = n_stars self.n_shift = n_shift self.n_calls = 0 def __call__(self, model, x, y, z=None, weights=None, **kwargs): fitted = super().__call__(model, x, y, z=z, weights=weights, **kwargs) if self.n_calls % self.n_stars < self.n_shift: iteration = self.n_calls // self.n_stars fitted.x_0 = fitted.x_0.value + 0.5 * (-1) ** iteration self.n_calls += 1 return fitted class _FailingFitter(TRFLSQFitter): """ A fitter that returns a fitted center outside the cutout for the star whose input flux is ``flux``, so that its fit always fails. """ def __init__(self, flux): super().__init__() self.flux = flux def __call__(self, model, x, y, z=None, weights=None, **kwargs): fitted = super().__call__(model, x, y, z=z, weights=weights, **kwargs) if model.flux.value == self.flux: fitted.x_0 = 1000.0 return fitted class TestConvergedFraction: """ Tests for the converged_fraction convergence criterion. """ @pytest.fixture def stars(self, epsf_test_data): return extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:20], size=11) @pytest.mark.parametrize('value', ['auto', True, None]) def test_invalid_type(self, value): match = 'converged_fraction must be a number' with pytest.raises(TypeError, match=match): EPSFBuilder(converged_fraction=value, progress_bar=False) @pytest.mark.parametrize('value', [0.0, 1.5, -0.1, np.nan]) def test_invalid_value(self, value): match = r'converged_fraction must be in the range \(0, 1\]' with pytest.raises(ValueError, match=match): EPSFBuilder(converged_fraction=value, progress_bar=False) def test_default(self): builder = EPSFBuilder(progress_bar=False) assert builder.converged_fraction == 0.95 @pytest.mark.parametrize(('fraction', 'n_shift', 'expected'), [(1.0, 0, True), (1.0, 1, False), (0.95, 1, True), (0.95, 3, False)]) def test_convergence(self, stars, fraction, n_shift, expected): """ With 20 stars, 1 unsettled star leaves 95 percent converged and 3 unsettled stars leave 85 percent converged. """ maxiters = 6 fitter = _ShiftingFitter(len(stars), n_shift) builder = EPSFBuilder(oversampling=1, maxiters=maxiters, center_accuracy=1e-2, converged_fraction=fraction, fitter=fitter, progress_bar=False) result = builder(stars) assert result.converged is expected assert result.final_converged_fraction == 1.0 - n_shift / 20 if expected: assert result.iterations < maxiters else: assert result.iterations == maxiters assert result.final_center_accuracy > 0.5 # The build converges when the achieved fraction reaches the # requested fraction assert result.converged == (result.final_converged_fraction >= builder.converged_fraction) def test_common_shift(self, stars): """ The star centers are compared after their median change is removed, but a build whose stars all keep moving together by more than the center accuracy is not converged. """ maxiters = 4 fitter = _ShiftingFitter(len(stars), len(stars)) builder = EPSFBuilder(oversampling=1, maxiters=maxiters, center_accuracy=1e-2, fitter=fitter, progress_bar=False) result = builder(stars) assert result.final_converged_fraction == 1.0 assert result.final_center_accuracy < 1e-2 assert result.converged is False assert result.iterations == maxiters # The common movement is what prevents the convergence info = result.iteration_info assert not np.any(info['converged']) assert np.all(info['common_center_shift'] > 0.4) def test_failed_fits_ignored(self, stars): """ Stars whose fit failed do not count toward the fraction. """ # The failing star is never updated, so its flux stays at the # input value that identifies it to the fitter failed_star = stars.all_stars[0] fitter = _FailingFitter(failed_star.flux) builder = EPSFBuilder(oversampling=1, maxiters=10, center_accuracy=1e-2, converged_fraction=1.0, fitter=fitter, progress_bar=False) with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) result = builder(stars) assert result.converged assert result.final_converged_fraction == 1.0 assert_array_equal(result.fitted_stars.all_stars[0].cutout_center, failed_star.cutout_center) def test_results_attribute(self, stars): builder = EPSFBuilder(oversampling=1, maxiters=10, center_accuracy=1e-2, progress_bar=False) result = builder(stars) assert 0.0 <= result.final_converged_fraction <= 1.0 assert result.converged == (result.final_converged_fraction >= builder.converged_fraction) def test_build_shares_spline_cache(epsf_test_data, monkeypatch): """ The spline interpolators are built once per ePSF and shared by the model copies made for every star fit, so the number of spline constructions does not depend on the number of stars. """ from photutils.psf import image_models counts = [] original = image_models.RectBivariateSpline class CountingSpline(original): def __init__(self, *args, **kwargs): counts.append(1) super().__init__(*args, **kwargs) monkeypatch.setattr(image_models, 'RectBivariateSpline', CountingSpline) n_constructions = [] for n_stars in (4, 12): stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:n_stars], size=11) counts.clear() builder = EPSFBuilder(oversampling=1, maxiters=2, progress_bar=False) builder(stars) n_constructions.append(len(counts)) assert n_constructions[0] == n_constructions[1] assert n_constructions[1] < 12 def _resample_residual_per_star(builder, star, epsf): """ Reference per-star implementation of the residual resampling. The normalized ePSF model is subtracted from the normalized star and each pixel residual is deposited on the oversampled grid points within 0.375 pixel, and at least one grid spacing, of the pixel center (the nearest grid point for an oversampling factor of one). Grid points without data are NaN. """ 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)) # Star pixel centers in the oversampled ePSF grid ny_over, nx_over = builder.oversampling x_over = xidx_centered * nx_over + epsf.origin[0] y_over = yidx_centered * ny_over + epsf.origin[1] epsf_shape = epsf.data.shape out_image = np.full(epsf_shape, np.nan) x_width = max(0.375 * nx_over, 1.0) if nx_over > 1 else 0.5 y_width = max(0.375 * ny_over, 1.0) if ny_over > 1 else 0.5 x_first = np.floor(x_over - x_width).astype(int) + 1 y_first = np.floor(y_over - y_width).astype(int) + 1 for j in range(int(np.ceil(2 * y_width))): yidx = y_first + j for i in range(int(np.ceil(2 * x_width))): xidx = x_first + i mask = ((xidx >= 0) & (xidx < epsf_shape[1]) & (yidx >= 0) & (yidx < epsf_shape[0]) & (xidx <= x_over + x_width) & (yidx <= y_over + y_width)) out_image[yidx[mask], xidx[mask]] = stardata[mask] return out_image @pytest.mark.parametrize('oversampling', [1, 2, (1, 2), 4]) def test_resample_residuals_matches_per_star(epsf_test_data, oversampling): """ Test that the vectorized residual stack equals the per-star resampling, including masked pixels, out-of-grid pixels, and excluded stars. """ stars = extract_stars(epsf_test_data['nddata'], epsf_test_data['init_stars'][:8], size=11) # Mask a few pixels in one star and shift another star so that # part of its footprint falls outside the ePSF grid. star0, star1, star2 = stars.all_stars[:3] star0.mask[2:4, 3:6] = True star1.cutout_center = (1.3, 9.2) star2._excluded_from_fit = True builder = EPSFBuilder(oversampling=oversampling, progress_bar=False) epsf = builder._create_initial_epsf(stars) rng = np.random.default_rng(0) epsf.data[:] = rng.uniform(0.0, 1.0, epsf.data.shape) stack = builder._resample_residuals(stars, epsf) assert stack.shape[0] == 7 for i, star in enumerate(stars.all_good_stars): expected = _resample_residual_per_star(builder, star, epsf) assert_array_equal(np.isnan(stack[i]), np.isnan(expected)) assert_allclose(stack[i], expected, equal_nan=True) # Processing the stars in several chunks gives the same stack chunked = builder._resample_residuals(stars, epsf, chunk_size=3) assert_array_equal(chunked, stack) astropy-photutils-24be529/photutils/psf/tests/test_epsf_stars.py000066400000000000000000002272671526452531300253770ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the epsf_stars module. """ import copy import warnings from multiprocessing.reduction import ForkingPickler import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.nddata import (InverseVariance, NDData, StdDevUncertainty, UnknownUncertainty, 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_allclose(info['array'], np.ones((5, 5)) * 0.1) # Test variance uncertainty (converted to standard deviation) data.uncertainty = VarianceUncertainty(np.ones((5, 5)) * 0.04) info = _prepare_uncertainty_info(data) assert info['type'] == 'uncertainty' assert_allclose(info['array'], np.ones((5, 5)) * 0.2) def test_prepare_uncertainty_info_no_represent_as(): """ Test uncertainty preparation for a duck-typed uncertainty that cannot be converted to a standard deviation. """ class PlainUncertainty: uncertainty_type = 'std' def __init__(self, array): self.array = array data = NDData(np.ones((5, 5))) data.uncertainty = PlainUncertainty(np.ones((5, 5)) * 0.5) info = _prepare_uncertainty_info(data) assert info['type'] == 'uncertainty' assert_allclose(info['array'], np.ones((5, 5)) * 0.5) 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 with a standard deviation array info = { 'type': 'uncertainty', 'array': np.ones((5, 5)) * 0.1, } 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 nonfinite weights. """ # Create weights with nonfinite 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 # Nonfinite 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 nonfinite weights (should generate warning) bad_weights = np.ones((5, 5)) bad_weights[2, 2] = np.inf bad_weights[1, 1] = np.nan match = 'Nonfinite weight values' with pytest.warns(AstropyUserWarning, match=match): star = EPSFStar(data, weights=bad_weights) # Check that nonfinite 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 match = 'contains invalid data' with pytest.warns(AstropyUserWarning, match=match): 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 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 nonfinite 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) match = 'outside the cutout bounds' with pytest.warns(AstropyUserWarning, match=match): star.cutout_center = [-1, 2] # Outside bounds 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 nonfinite origin match = 'Origin coordinates must be finite' with pytest.raises(ValueError, match=match): EPSFStar(data, origin=[np.inf, 2]) # Test scalar origin match = 'Origin must have exactly 2 elements' with pytest.raises(ValueError, match=match): EPSFStar(data, origin=5) def test_quantity_data_rejected(self): """ Test that Quantity data inputs raise a clear TypeError. """ data = np.ones((5, 5)) * u.Jy match = 'Quantity inputs are not supported' with pytest.raises(TypeError, match=match): EPSFStar(data) 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 match = 'All unmasked data values in star cutout are zero' with pytest.warns(AstropyUserWarning, match=match): star = EPSFStar(data) # 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_array_copy_false(self): """ Test that np.array(star, copy=False) works without warnings (NumPy 2 __array__ signature) and that the default is a copy. """ data = np.random.default_rng(42).random((5, 5)) star = EPSFStar(data) arr = np.array(star, copy=False) assert_array_equal(arr, data) arr = np.array(star) arr[0, 0] = -100.0 assert star.data[0, 0] != -100.0 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 expected_slices = (slice(20, 27), slice(10, 19)) assert star.slices == expected_slices # Test bbox property bbox = star.bbox assert bbox.ixmin == 10 assert bbox.ixmax == 19 assert bbox.iymin == 20 assert bbox.iymax == 27 def test_slices_bbox_rectangular_roundtrip(self): """ Regression test that slices/bbox are correct for nonsquare cutouts. Indexing the parent image with star.slices must return the cutout. """ img = np.arange(60 * 80, dtype=float).reshape(60, 80) stars = extract_stars(NDData(img), Table({'x': [40.0], 'y': [30.0]}), size=(11, 15)) star = stars[0] assert star.data.shape == (11, 15) assert_array_equal(img[star.slices], star.data) def test_flux_update_invalidates_normalization(self): """ Regression test that updating flux (including on a shallow copy, as the ePSF builder does) refreshes the cached normalized data values. """ star = EPSFStar(np.ones((5, 5))) norm = star._data_values_normalized.copy() star2 = copy.copy(star) star2.flux = star.flux * 2 assert_allclose(star2._data_values_normalized, norm / 2) star.flux = star.flux * 4 assert_allclose(star._data_values_normalized, norm / 4) 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 match = 'y-coordinate .* is outside the cutout bounds' with pytest.warns(AstropyUserWarning, match=match): star.cutout_center = (2.0, -1.0) # y < 0 # Test y-coordinate at upper bound with pytest.warns(AstropyUserWarning, match=match): star.cutout_center = (2.0, 6.0) # y >= shape[0] 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) # The star flux is the sum of the cutout, which can differ from # the input flux by rounding, so the residual is not exactly # zero on every platform. assert_allclose(residual, 0.0, atol=1e-12) 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') # Test list with invalid element types with pytest.raises(TypeError, match=match): EPSFStars([star1, 'not_a_star']) 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_delitem_invalidates_caches(self): """ Regression test that deleting a star invalidates the cached star counts and flat star list. """ stars = [EPSFStar(np.ones((5, 5))) for _ in range(3)] stars_obj = EPSFStars(stars) # Populate the cached properties assert stars_obj.n_stars == 3 assert stars_obj.n_all_stars == 3 assert len(stars_obj.all_stars) == 3 del stars_obj[0] assert stars_obj.n_stars == len(stars_obj) assert stars_obj.n_all_stars == len(stars_obj) assert len(stars_obj.all_stars) == len(stars_obj) 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. """ data = np.random.default_rng(42).random((5, 5)) stars = EPSFStars([EPSFStar(data)]) unpickled = ForkingPickler.loads(ForkingPickler.dumps(stars)) assert len(unpickled) == 1 assert_array_equal(unpickled[0].data, data) def test_getattr_with_linked_stars(self, simple_wcs): """ Regression test that attribute delegation works for a mix of EPSFStar and multistar LinkedEPSFStar objects (ragged per-star values return an object array). """ star = EPSFStar(np.ones((5, 5))) linked_star1 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs) linked_star2 = EPSFStar(np.ones((9, 9)), wcs_large=simple_wcs) linked = LinkedEPSFStar([linked_star1, linked_star2]) stars = EPSFStars([star, linked]) centers = stars.cutout_center assert len(centers) == 2 assert_array_equal(centers[0], star.cutout_center) assert_array_equal(centers[1], linked.cutout_center) fluxes = stars.flux assert len(fluxes) == 2 assert fluxes[0] == star.flux assert_array_equal(fluxes[1], linked.flux) excluded = stars._excluded_from_fit assert len(excluded) == 2 assert not excluded[0] assert_array_equal(excluded[1], [False, False]) 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 match = 'have all been excluded' with pytest.warns(AstropyUserWarning, match=match): linked.constrain_centers() 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() @pytest.mark.parametrize('ctype', [('RA---TAN', 'DEC--TAN'), ('DEC--TAN', 'RA---TAN')]) def test_constrain_centers_wcs_axis_order(self, ctype): """ Test that constrain_centers handles a WCS whose latitude axis comes first. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [25, 25] wcs.wcs.crval = [200, 30] if ctype[0].startswith('RA') else [30, 200] wcs.wcs.cdelt = [0.01, 0.01] wcs.wcs.ctype = ctype centers = np.array([(3.1, 3.1), (2.9, 2.9), (3.0, 3.2)]) stars = [EPSFStar(np.ones((7, 7)), wcs_large=wcs, cutout_center=center, origin=(20, 20)) for center in centers] linked = LinkedEPSFStar(stars) linked.constrain_centers() expected = np.mean(centers, axis=0) for star in stars: assert_allclose(star.cutout_center, expected, atol=1e-4) 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. It 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 match = 'Cannot constrain centers' with pytest.warns(AstropyUserWarning, match=match): linked.constrain_centers() def test_constrain_fluxes(self, simple_wcs): """ Test that constrain_fluxes sets the fluxes of the good linked stars to their mean and leaves excluded stars unchanged. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs, flux=10.0) star2 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs, flux=20.0) star3 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs, flux=90.0) star3._excluded_from_fit = True linked = LinkedEPSFStar([star1, star2, star3]) linked.constrain_fluxes() assert star1.flux == 15.0 assert star2.flux == 15.0 assert star3.flux == 90.0 def test_constrain_fluxes_single_star(self, simple_wcs): """ Test that constrain_fluxes is a no-op for a single star. """ star = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs, flux=10.0) linked = LinkedEPSFStar([star]) linked.constrain_fluxes() assert star.flux == 10.0 def test_constrain_fluxes_all_excluded(self, simple_wcs): """ Test that constrain_fluxes warns and does nothing when all stars are excluded. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs, flux=10.0) star2 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs, flux=20.0) star1._excluded_from_fit = True star2._excluded_from_fit = True linked = LinkedEPSFStar([star1, star2]) match = 'Cannot constrain fluxes' with pytest.warns(AstropyUserWarning, match=match): linked.constrain_fluxes() assert star1.flux == 10.0 assert star2.flux == 20.0 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_excluded_from_fit_delegation(self, simple_wcs): """ Regression test that _excluded_from_fit returns a per-star array instead of raising AttributeError. """ star1 = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) star2 = EPSFStar(np.ones((7, 7)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star1, star2]) excluded = linked._excluded_from_fit assert_array_equal(excluded, [False, False]) star2._excluded_from_fit = True excluded = linked._excluded_from_fit assert_array_equal(excluded, [False, True]) def test_getattr_private_attribute_error(self, simple_wcs): """ Test that accessing nonexistent private attributes raises error. """ star = EPSFStar(np.ones((5, 5)), wcs_large=simple_wcs) linked = LinkedEPSFStar([star]) # Accessing nonexistent 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 = '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_array_input(self, stars_data, stars_nddata, stars_table): """ Test that a plain array gives the same stars as NDData. """ stars = extract_stars(stars_data, stars_table, size=11) expected = extract_stars(stars_nddata, stars_table, size=11) assert len(stars) == len(expected) == 4 for star, expected_star in zip(stars, expected, strict=True): assert_array_equal(star.data, expected_star.data) assert_array_equal(star.weights, np.ones((11, 11))) assert star.flux == expected_star.flux assert_array_equal(star.center, expected_star.center) assert star.wcs_large is None def test_array_list_input(self, stars_data, stars_nddata, stars_table): """ Test a list of arrays and a list mixing arrays and NDData. """ catalogs = [stars_table, stars_table] stars = extract_stars([stars_data, stars_data], catalogs, size=11) assert len(stars) == 8 stars = extract_stars([stars_data, stars_nddata], catalogs, size=11) assert len(stars) == 8 assert_array_equal(stars[0].data, stars[4].data) def test_masked_array_input(self, stars_data, stars_table): """ Test that the mask of a masked array zeros the star weights. """ mask = np.zeros(stars_data.shape, dtype=bool) mask[15, 15] = True data = np.ma.array(stars_data, mask=mask) stars = extract_stars(data, stars_table, size=11) assert stars[0].weights[5, 5] == 0 assert np.count_nonzero(stars[0].weights == 0) == 1 assert_array_equal(stars[1].weights, np.ones((11, 11))) def test_unmasked_masked_array_input(self, stars_data, stars_table): """ Test a masked array that has no masked values. """ stars = extract_stars(np.ma.array(stars_data), stars_table, size=11) assert len(stars) == 4 assert_array_equal(stars[0].weights, np.ones((11, 11))) def test_quantity_input(self, stars_data, stars_table): """ Test that the unit of a Quantity array is ignored. """ stars = extract_stars(stars_data * u.Jy, stars_table, size=11) expected = extract_stars(stars_data, stars_table, size=11) assert not isinstance(stars[0].data, u.Quantity) assert_array_equal(stars[0].data, expected[0].data) def test_array_input_invalid(self, simple_data, simple_table): """ Test invalid array inputs. """ match = 'All input images must be 2D' with pytest.raises(ValueError, match=match): extract_stars(np.ones(3), simple_table) with pytest.raises(ValueError, match=match): extract_stars(np.ones((2, 50, 50)), simple_table) # A nested list is not interpreted as a 2D image match = 'All data elements must be 2D arrays or NDData objects' with pytest.raises(TypeError, match=match): extract_stars(simple_data.tolist(), simple_table) skycoord_table = Table() skycoord_table['skycoord'] = [SkyCoord(0, 0, unit='deg')] match = 'must be an NDData object with a wcs attribute' with pytest.raises(ValueError, match=match): extract_stars(simple_data, skycoord_table) with pytest.raises(ValueError, match=match): extract_stars([simple_data, simple_data], skycoord_table) def test_empty_catalog(self, simple_nddata): """ Test extraction with empty catalog. """ empty_table = Table() empty_table['x'] = [] empty_table['y'] = [] match = 'is empty' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(simple_nddata, empty_table) assert len(stars) == 0 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] match = '2 star cutout\\(s\\) were not extracted' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(simple_nddata, table, size=11) assert len(stars) == 0 def test_star_cutout_ends_at_image_edge(self, simple_nddata): """ Test extraction with a star whose cutout ends exactly at the lower edge of the image. Regression test for the array-valued ``size`` being compared to a tuple in ``overlap_slices``. """ table = Table() table['x'] = [-6, 25] table['y'] = [25, -6] match = '2 star cutout\\(s\\) were not extracted' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(simple_nddata, table, size=11) assert len(stars) == 0 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 = 'data must be a 2D array, an NDData object, or a list' 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 2D arrays or 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 = 'All input images must be 2D' 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 = 'must be an NDData object with 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)) # The WCS reference pixel (1-based crpix [25, 25]) maps the # (0, 0) sky position to the 0-based pixel position (24, 24) assert len(stars) == 1 assert_allclose(stars[0].center, (24, 24)) 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 match = '1 star cutout\\(s\\) were not extracted' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars([nddata1, nddata2], table, size=11) # The second image star is outside bounds so only 1 is extracted assert len(stars) == 1 def test_extract_stars_flux_column(self): """ Test that a catalog flux column is used for the star fluxes. """ data = np.ones((50, 50)) catalog = Table({'x': [25.0], 'y': [25.0], 'flux': [12345.0]}) stars = extract_stars(NDData(data), catalog, size=11) assert stars[0].flux == 12345.0 def test_extract_stars_no_flux_column(self): """ Test that star fluxes are estimated from the cutout data when the catalog has no flux column. """ data = np.ones((50, 50)) catalog = Table({'x': [25.0], 'y': [25.0]}) stars = extract_stars(NDData(data), catalog, size=11) assert_allclose(stars[0].flux, 121.0) 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 nonfinite weights nonfinite_warnings = [msg for msg in warning_messages if 'nonfinite 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 nonfinite weights in uncertainty emit warning. """ data = np.ones((50, 50)) * 100 nddata = NDData(data) # Create uncertainty with nonfinite 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]}) match = 'nonfinite weight values' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(nddata, table, size=11) # Star should still be extracted (nonfinite weights set to 0) assert len(stars) == 1 def test_extract_stars_weights_input_not_modified(self): """ Regression test that extract_stars does not modify a user-supplied float weights uncertainty array when masking is applied, and that masked pixels get zero weight in the star. """ class WeightsUncertainty(StdDevUncertainty): @property def uncertainty_type(self): return 'weights' data = np.ones((50, 50)) weights = np.ones((50, 50)) weights_orig = weights.copy() mask = np.zeros((50, 50), dtype=bool) mask[25, 25] = True nddata = NDData(data, uncertainty=WeightsUncertainty(weights), mask=mask) table = Table({'x': [25.0], 'y': [25.0]}) stars = extract_stars(nddata, table, size=11) assert len(stars) == 1 # The masked pixel has zero weight in the star cutout assert stars[0].weights[5, 5] == 0.0 # The user's weights array is unchanged assert_array_equal(weights, weights_orig) def test_extract_stars_nonfinite_positions(self, simple_nddata): """ Regression test that nonfinite source positions are skipped with a warning instead of raising an error. """ table = Table({'x': [25.0, np.nan], 'y': [25.0, 30.0]}) match = 'positions were not finite' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(simple_nddata, table, size=11) assert len(stars) == 1 assert_allclose(stars[0].center, (25, 25)) 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 be an NDData object with 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 = 'must be an NDData object with a wcs attribute' with pytest.raises(ValueError, match=match): extract_stars(nddata, table, size=11) def test_validate_multiple_catalogs_mixed_wcs(self, simple_wcs): """ Test validation with multiple catalogs where each catalog pairs with a usable image. A missing WCS is allowed on an image whose catalog has pixel coordinates. A skycoord-only catalog requires a WCS only on its corresponding image. """ 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')] # Each catalog pairs with a usable image, so extraction # succeeds stars = extract_stars([nddata1, nddata2], [table1, table2], size=11) assert len(stars) == 2 # A skycoord-only catalog paired with an image lacking a WCS # raises an error match = 'the corresponding image must be an NDData object' with pytest.raises(ValueError, match=match): extract_stars([nddata1, nddata2], [table2, table1], 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_unknown_uncertainty(self, epsf_test_data): """ Test that an uncertainty of unknown type is treated as a standard deviation. """ rng = np.random.default_rng(seed=0) data = epsf_test_data['nddata'].data error = rng.uniform(0.5, 2.0, size=data.shape) ndd1 = NDData(data, uncertainty=StdDevUncertainty(error)) ndd2 = NDData(data, uncertainty=UnknownUncertainty(error)) init = epsf_test_data['init_stars'] stars1 = extract_stars(ndd1, init, size=25) stars2 = extract_stars(ndd2, init, size=25) assert_allclose(stars2.weights, stars1.weights) def test_extract_stars_nonfinite_weights(self, epsf_test_data): """ Test extract_stars with sparse zero uncertainty values that create nonfinite weights at specific locations. The stars should still be extracted successfully, with only the expected warning about nonfinite 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 (nonzero) values, # but include some zero uncertainty values at specific locations # within the star cutout regions to trigger nonfinite 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 nonfinite weights warning. Stars should # still be extracted successfully match = 'nonfinite 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 = 'nonfinite 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 def test_star_nonfinite_flux_raises(): """ Regression test that a nonfinite flux raises a clear error instead of crashing later deep inside the ePSF fitter. """ match = 'flux must be a finite value' with pytest.raises(ValueError, match=match): EPSFStar(np.ones((5, 5)), flux=np.nan) star = EPSFStar(np.ones((5, 5))) with pytest.raises(ValueError, match=match): star.flux = np.inf def test_extract_stars_nonfinite_flux_column(): """ Regression test that nonfinite catalog flux values are skipped with a warning, like nonfinite positions. """ data = np.ones((50, 50)) catalog = Table({'x': [25.0, 10.0], 'y': [25.0, 10.0], 'flux': [100.0, np.nan]}) match = 'flux values were not finite' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(NDData(data), catalog, size=11) assert len(stars) == 1 assert stars[0].flux == 100.0 def test_zero_flux_star_normalization_no_warning(): """ Regression test that normalizing a zero-flux star does not leak a RuntimeWarning during ePSF building. """ match = 'All unmasked data values in star cutout are zero' with pytest.warns(AstropyUserWarning, match=match): star = EPSFStar(np.zeros((5, 5))) with warnings.catch_warnings(): warnings.simplefilter('error') values = star._data_values_normalized assert np.all(np.isnan(values)) def test_masked_array_data_raises(): data = np.ma.MaskedArray(np.ones((5, 5)), mask=np.zeros((5, 5), dtype=bool)) match = 'MaskedArray inputs are not supported' with pytest.raises(TypeError, match=match): EPSFStar(data) def test_non_integral_origin_raises(): match = 'origin must have integer values' with pytest.raises(ValueError, match=match): EPSFStar(np.ones((5, 5)), origin=(1.7, 2.3)) star = EPSFStar(np.ones((5, 5)), origin=(1.0, 2.0)) assert_array_equal(star.origin, [1, 2]) def test_container_getattr_private_and_empty(): """ Regression test that EPSFStars matches LinkedEPSFStar in not delegating private attributes (except _excluded_from_fit) and that empty containers raise AttributeError. """ star = EPSFStar(np.ones((5, 5))) stars = EPSFStars([star]) assert_array_equal(stars._excluded_from_fit, [False]) with pytest.raises(AttributeError): stars._fit_error_status # noqa: B018 empty = EPSFStars([]) match = ("'EPSFStars' object is empty and has no attribute " "'cutout_center'") with pytest.raises(AttributeError, match=match): empty.cutout_center # noqa: B018 def test_epsfstars_array_conversion(): """ Regression test that a single-star EPSFStars container (e.g., from indexing) converts to the 2D star cutout, so that it can be passed directly to matplotlib's imshow, and that a multistar container stacks the cutouts along the first axis. """ data1 = np.arange(25.0).reshape(5, 5) data2 = data1 * 2.0 stars = EPSFStars([EPSFStar(data1), EPSFStar(data2)]) single = np.asarray(stars[0]) assert single.shape == (5, 5) assert_array_equal(single, data1) stacked = np.asarray(stars) assert stacked.shape == (2, 5, 5) assert_array_equal(stacked[1], data2) # dtype and copy keywords are honored assert np.asarray(stars[0], dtype=np.float32).dtype == np.float32 arr = np.array(stars[0], copy=True) arr[0, 0] = -99.0 assert stars[0].data[0, 0] == 0.0 astropy-photutils-24be529/photutils/psf/tests/test_flags.py000066400000000000000000000520221526452531300243030ustar00rootroot00000000000000# 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, _PSFFlags, _update_decode_docstring, decode_psf_flags) from photutils.utils._flags import FlagDefinition from photutils.utils.exceptions import PhotutilsDeprecationWarning 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 == ['position_outside_image'] decoded = decode_psf_flags(4) assert decoded == ['non_positive_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 == ['parameter_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_local_bkg'] decoded = decode_psf_flags(4096) assert decoded == ['position_outside_fit_region'] # Test combination of flags decoded = decode_psf_flags(5) # bits 1 and 4 assert set(decoded) == {'n_pixels_fit_partial', 'non_positive_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 + 4096) # 8191 decoded = decode_psf_flags(all_flags) expected_all = ['n_pixels_fit_partial', 'position_outside_image', 'non_positive_flux', 'no_convergence', 'no_covariance', 'parameter_near_bound', 'no_overlap', 'fully_masked', 'too_few_pixels', 'non_finite_position', 'non_finite_flux', 'non_finite_local_bkg', 'position_outside_fit_region'] assert set(decoded) == set(expected_all) assert len(decoded) == 13 # 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] == ['position_outside_image'] assert set(decoded_list[3]) == {'n_pixels_fit_partial', 'non_positive_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] == ['parameter_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 'non_positive_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', 'position_outside_image', 'non_positive_flux', 'no_convergence', 'no_covariance', 'parameter_near_bound', 'no_overlap', 'fully_masked', 'too_few_pixels', 'non_finite_position', 'non_finite_flux', 'non_finite_local_bkg', 'position_outside_fit_region'] assert set(decoded) == set(expected_all) match = 'Flag value must be a nonnegative 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 (shape preserved as nested lists) flag_2d = np.array([[0, 1], [8, 136]]) decoded = decode_psf_flags(flag_2d) assert len(decoded) == 2 # Shape preserved assert decoded[0][0] == [] assert decoded[0][1] == ['n_pixels_fit_partial'] assert decoded[1][0] == ['no_convergence'] assert set(decoded[1][1]) == {'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, 'POSITION_OUTSIDE_IMAGE': 2, 'NON_POSITIVE_FLUX': 4, 'NO_CONVERGENCE': 8, 'NO_COVARIANCE': 16, 'PARAMETER_NEAR_BOUND': 32, 'NO_OVERLAP': 64, 'FULLY_MASKED': 128, 'TOO_FEW_PIXELS': 256, 'NON_FINITE_POSITION': 512, 'NON_FINITE_FLUX': 1024, 'NON_FINITE_LOCAL_BKG': 2048, 'POSITION_OUTSIDE_FIT_REGION': 4096, } 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, 4096] assert set(bit_values) == set(expected_bits) assert len(bit_values) == 13 # Test names property names = PSF_FLAGS.names expected_names = [ 'n_pixels_fit_partial', 'position_outside_image', 'non_positive_flux', 'no_convergence', 'no_covariance', 'parameter_near_bound', 'no_overlap', 'fully_masked', 'too_few_pixels', 'non_finite_position', 'non_finite_flux', 'non_finite_local_bkg', 'position_outside_fit_region', ] assert set(names) == set(expected_names) assert len(names) == 13 # Test flag_dict property flag_dict = PSF_FLAGS.flag_dict assert isinstance(flag_dict, dict) assert len(flag_dict) == 13 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) == 13 for flag_def in all_flags: assert isinstance(flag_def, FlagDefinition) 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_local_bkg' assert PSF_FLAGS.get_name(4096) == 'position_outside_fit_region' # 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' in desc1 assert 'smaller than full' in desc1 assert 'fit_shape' in desc1 desc8 = PSF_FLAGS.get_description(8) assert 'possible nonconvergence' 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, FlagDefinition) 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, FlagDefinition) 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) @pytest.mark.parametrize(('old_name', 'new_name', 'since'), [ ('npixfit_partial', 'n_pixels_fit_partial', '3.0'), ('outside_bounds', 'position_outside_image', '3.1'), ('non_finite_localbkg', 'non_finite_local_bkg', '3.1'), ('negative_flux', 'non_positive_flux', '3.1'), ('near_bound', 'parameter_near_bound', '3.1'), ]) def test_psf_flags_deprecated_names(old_name, new_name, since): """ Test that deprecated flag and constant names resolve to the new names and warn with the version in which each was deprecated. """ match = (f"The flag name '{old_name}' is deprecated in version " f"{since}. Use '{new_name}' instead. It will be removed in " 'version 4.0') with pytest.warns(PhotutilsDeprecationWarning, match=match): deprecated_def = PSF_FLAGS.get_definition(old_name) assert deprecated_def is PSF_FLAGS.get_definition(new_name) match = (f"'{old_name.upper()}' attribute was deprecated in version " f"{since}. Use '{new_name.upper()}' instead. It will be " 'removed in version 4.0') with pytest.warns(PhotutilsDeprecationWarning, match=match): value = getattr(PSF_FLAGS, old_name.upper()) assert value == getattr(PSF_FLAGS, new_name.upper()) def test_psf_flag_definition(): """ Test FlagDefinition dataclass. """ # Create a flag definition flag_def = FlagDefinition( 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 = FlagDefinition( bit_value=1, name='test_flag', description='test description', detailed_description='detailed test description', ) assert flag_def == flag_def2 # Test inequality flag_def3 = FlagDefinition( 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 flag_name in PSF_FLAGS.names: const_value = getattr(PSF_FLAGS, flag_name.upper()) decoded_const = decode_psf_flags(const_value) assert len(decoded_const) == 1 # The decoded name should match the constant name (lowercase) assert decoded_const[0] == flag_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) == 13 # 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) == 13 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'`` : The bitwise quality flags" in docstring # Should have all dynamic flag descriptions dynamic_flags = [ 'indicating partial PSF fitting', 'outside the bounds of the input image', 'negative or zero, which is nonphysical', 'may not have converged to a stable solution', 'covariance matrix is not available', 'very close to their imposed bounds', 'no overlap with the input image', 'All pixels in the source fitting region are masked', 'Insufficient unmasked pixels available for reliable PSF fitting', 'outside the region of fitted data', ] 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'``", "``'position_outside_image'``", "``'non_positive_flux'``", "``'no_convergence'``", "``'no_covariance'``", "``'parameter_near_bound'``", "``'no_overlap'``", "``'fully_masked'``", "``'too_few_pixels'``", ] 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 = [ 'indicating partial PSF fitting', 'outside the bounds of the input image', 'negative or zero, which is nonphysical', 'may not have converged to a stable solution', 'covariance matrix is not available', 'very close to their imposed bounds', 'no overlap with the input image', 'All pixels in the source fitting region are masked', 'Insufficient unmasked pixels available for reliable PSF 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 + 4096) # 8191 decoded = decode_psf_flags(all_flags, return_bit_values=True) expected_all = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096] assert set(decoded) == set(expected_all) assert len(decoded) == 13 # 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-24be529/photutils/psf/tests/test_functional_models.py000066400000000000000000001276061526452531300267270ustar00rootroot00000000000000# 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 scipy.special import erf from photutils.psf import (AiryDiskPRF, AiryDiskPSF, CircularGaussianPRF, CircularGaussianPSF, CircularGaussianSigmaPRF, GaussianPRF, GaussianPSF, MoffatPRF, MoffatPSF, functional_models) 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) if 'theta' in model.param_names: model.theta = model.theta.value * u.deg elliptical = 'x_fwhm' in model.param_names data = model(xx, yy) if use_units: data = data.value assert_allclose(data.sum(), model.flux.value) if elliptical: assert_allclose(model.x_sigma, model.x_fwhm * gaussian_fwhm_to_sigma) assert_allclose(model.y_sigma, model.y_fwhm * gaussian_fwhm_to_sigma) 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) else: assert_allclose(model.sigma, model.fwhm * gaussian_fwhm_to_sigma) 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) if elliptical: 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) elif 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) if elliptical: 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) else: 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) class TestFitDeriv: """ Tests of the analytic model derivatives against numerical derivatives. """ # Define (x, y) coordinates spanning the model peak yy, xx = np.mgrid[20:30, 19:29] xx = xx.astype(float) yy = yy.astype(float) gaussian_params = (71.4, 24.3, 25.2, 10.1, 5.82, 21.7) circular_params = (71.4, 24.3, 25.2, 10.1) def numerical_deriv(self, model, params, index, step=1e-6): """ Calculate the central-difference derivative of a model with respect to a single parameter. Parameters ---------- model : `~astropy.modeling.Fittable2DModel` The model to evaluate. params : tuple The model parameter values. index : int The index of the parameter to differentiate. step : float, optional The parameter step size. Returns ------- result : `~numpy.ndarray` The numerical partial derivative. """ params_hi = list(params) params_lo = list(params) params_hi[index] += step params_lo[index] -= step data_hi = model.evaluate(self.xx, self.yy, *params_hi) data_lo = model.evaluate(self.xx, self.yy, *params_lo) return (data_hi - data_lo) / (2.0 * step) @pytest.mark.parametrize(('model_class', 'params'), [(GaussianPSF, gaussian_params), (CircularGaussianPSF, circular_params), (CircularGaussianPRF, circular_params), (CircularGaussianSigmaPRF, circular_params), (MoffatPSF, (71.4, 24.3, 25.2, 8.1, 7.2)), (MoffatPSF, (71.4, 24.0, 25.0, 0.9, 1.5)), (AiryDiskPSF, (71.4, 24.3, 25.2, 5.3)), (AiryDiskPSF, (71.4, 24.0, 25.0, 1.1))]) def test_fit_deriv(self, model_class, params): model = model_class() derivs = model.fit_deriv(self.xx, self.yy, *params) assert len(derivs) == len(params) for index in range(len(params)): numerical = self.numerical_deriv(model, params, index) assert_allclose(derivs[index], numerical, rtol=1e-5, atol=1e-9) def test_circular_fwhm_deriv(self): """ Test that the circular Gaussian FWHM derivative is the sum of the x and y FWHM derivatives of the elliptical Gaussian model. """ flux, x_0, y_0, fwhm = self.circular_params circular = CircularGaussianPSF.fit_deriv(self.xx, self.yy, flux, x_0, y_0, fwhm) elliptical = GaussianPSF.fit_deriv(self.xx, self.yy, flux, x_0, y_0, fwhm, fwhm, 0.0) assert_allclose(circular[3], elliptical[3] + elliptical[4]) def test_gaussian_fit_deriv_zero_flux(self): """ Test that the flux derivative is finite when the flux is zero. """ derivs = GaussianPSF.fit_deriv(1.0, 1.0, 0.0, 0.0, 0.0, 2.0, 3.0, 10.0) assert np.isfinite(derivs[0]) @pytest.mark.parametrize('params', [ gaussian_params, (71.4, 24.3, 25.2, 1.0, 1.4, 45.0), (71.4, 24.5, 24.5, 0.8, 2.4, -60.0), (71.4, 24.0, 25.0, 2.0, 3.0, 0.0), (71.4, 24.3, 25.2, 3.0, 1.2, 90.0), (71.4, 24.3, 25.2, 2.0, 2.0, 30.0), ]) def test_gaussian_prf_fit_deriv(self, params): """ Test the GaussianPRF derivatives for undersampled, rotated, and unrotated Gaussians, including sources on a pixel corner and at a pixel center. The step of the numerical derivative is larger than for the other models because the rotated model is a difference of terms of order unity, which leaves rounding noise in a difference over a very small step. """ model = GaussianPRF() derivs = model.fit_deriv(self.xx, self.yy, *params) assert len(derivs) == len(params) for index in range(len(params)): assert derivs[index].shape == self.xx.shape numerical = self.numerical_deriv(model, params, index, step=1e-4) assert_allclose(derivs[index], numerical, rtol=1e-5, atol=1e-8) @pytest.mark.parametrize('fwhm', [0.9, 2.1]) def test_circular_prf_fit_deriv(self, fwhm): """ Test the derivatives of the circular Gaussian PRF models, including an undersampled Gaussian. """ params = (71.4, 24.5, 25.2, fwhm) sigma_params = (*params[:3], fwhm * gaussian_fwhm_to_sigma) for model, model_params in ((CircularGaussianPRF(), params), (CircularGaussianSigmaPRF(), sigma_params)): derivs = model.fit_deriv(self.xx, self.yy, *model_params) for index in range(len(model_params)): numerical = self.numerical_deriv(model, model_params, index) assert_allclose(derivs[index], numerical, rtol=1e-5, atol=1e-8) def test_circular_prf_fwhm_deriv(self): """ Test that the circular Gaussian PRF FWHM derivative is the sum of the x and y FWHM derivatives of the elliptical Gaussian PRF. """ flux, x_0, y_0, fwhm = self.circular_params circular = CircularGaussianPRF.fit_deriv(self.xx, self.yy, flux, x_0, y_0, fwhm) elliptical = GaussianPRF.fit_deriv(self.xx, self.yy, flux, x_0, y_0, fwhm, fwhm, 0.0) assert_allclose(circular[3], elliptical[3] + elliptical[4]) for index in range(3): assert_allclose(circular[index], elliptical[index]) def test_gaussian_prf_fit_deriv_theta_quantity(self): """ Test that a Quantity theta gives the same derivatives as a float theta in degrees. """ params = (71.4, 24.3, 25.2, 2.0, 3.0) derivs1 = GaussianPRF.fit_deriv(self.xx, self.yy, *params, 30.0) derivs2 = GaussianPRF.fit_deriv(self.xx, self.yy, *params, 30.0 * u.deg) for deriv1, deriv2 in zip(derivs1, derivs2, strict=True): assert_allclose(deriv1, deriv2) @pytest.mark.parametrize(('model_class', 'params'), [ (GaussianPRF, (0.0, 0.0, 0.0, 2.0, 3.0, 10.0)), (CircularGaussianPRF, (0.0, 0.0, 0.0, 2.0)), (CircularGaussianSigmaPRF, (0.0, 0.0, 0.0, 2.0)), ]) def test_prf_fit_deriv_zero_flux(self, model_class, params): """ Test that the flux derivative is the model for unit flux when the flux is zero. """ derivs = model_class.fit_deriv(1.0, 1.0, *params) expected = model_class().evaluate(1.0, 1.0, 1.0, *params[1:]) assert_allclose(derivs[0], expected) assert derivs[0] > 0 def test_airy_fit_deriv_at_peak(self): """ Test that the Airy disk derivatives are finite at the peak, where the Bessel function ratios take their limiting values. """ params = (2.0, 0.0, 0.0, 2.0) derivs = AiryDiskPSF.fit_deriv(0.0, 0.0, *params) peak = AiryDiskPSF(flux=1.0, radius=2.0)(0.0, 0.0) assert_allclose(derivs[0], peak) assert_allclose(derivs[1:3], 0.0) # The peak scales as radius**-2 assert_allclose(derivs[3], -2.0 * 2.0 * peak / 2.0) @pytest.mark.parametrize('offset', [1.0e-170, 1.0e-8, 0.19, 0.2]) def test_airy_fit_deriv_near_peak(self, offset): """ Test the Airy disk derivatives close to the peak, including an offset whose square underflows and offsets on either side of the radius where the Bessel function ratio changes from its power series to the recurrence. """ params = (71.4, 0.0, 0.0, 2.0) model = AiryDiskPSF() derivs = AiryDiskPSF.fit_deriv(offset, 0.0, *params) step = 1.0e-6 for index in range(len(params)): params_hi = list(params) params_lo = list(params) params_hi[index] += step params_lo[index] -= step numerical = (model.evaluate(offset, 0.0, *params_hi) - model.evaluate(offset, 0.0, *params_lo)) / ( 2.0 * step) assert_allclose(derivs[index], numerical, rtol=1e-6, atol=1e-8) def test_moffat_fit_deriv_beta_one(self): """ Test that the Moffat derivatives are finite at beta = 1, where the normalized profile is zero everywhere. """ derivs = MoffatPSF.fit_deriv(self.xx, self.yy, 71.4, 24.3, 25.2, 2.0, 1.0) for deriv in derivs[:4]: assert_allclose(deriv, 0.0) assert np.all(derivs[4] > 0) def test_gaussian_prf_fit_free_shape(self): """ Test fitting an undersampled rotated Gaussian PRF with free shape parameters using the analytic derivatives. """ model = GaussianPRF(flux=71.4, x_0=12.3, y_0=11.8, x_fwhm=1.6, y_fwhm=2.7, theta=33.0) yy, xx = np.mgrid[0:25, 0:25] data = model(xx, yy) model_init = GaussianPRF(flux=60.0, x_0=12.0, y_0=12.0, x_fwhm=2.0, y_fwhm=2.5, theta=20.0) for name in ('x_fwhm', 'y_fwhm', 'theta'): getattr(model_init, name).fixed = False fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.parameters, model.parameters, rtol=1e-6) def test_circular_fit_free_fwhm(self): """ Test fitting a circular Gaussian with a free FWHM using the analytic derivatives. """ model = CircularGaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=6.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) model_init = CircularGaussianPSF(flux=50.0, x_0=23.0, y_0=27.0, fwhm=8.0) model_init.fwhm.fixed = False fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.flux.value, model.flux.value, rtol=1e-6) assert_allclose(fit_model.fwhm.value, model.fwhm.value, rtol=1e-6) def test_gaussian_prf_theta_quantity(): """ Test that a float theta (degrees) and an equivalent Quantity theta give the same model values. """ assert_allclose(GaussianPRF(theta=30.0)(1.0, 2.0), GaussianPRF(theta=30 * u.deg)(1.0, 2.0)) def test_circular_prf_fwhm_sigma_equivalence(): """ Test that CircularGaussianPRF and CircularGaussianSigmaPRF give identical values for equivalent widths. """ fwhm = 2.7 model1 = CircularGaussianPRF(fwhm=fwhm) model2 = CircularGaussianSigmaPRF(sigma=fwhm * gaussian_fwhm_to_sigma) yy, xx = np.mgrid[-5:6, -5:6] assert_allclose(model1(xx, yy), model2(xx, yy)) def test_gaussian_prf_matches_analytic_integral(): """ Test that GaussianPRF at theta=0 equals the erf-based analytic pixel integral of GaussianPSF. """ flux = 71.4 x_0 = 0.3 y_0 = -0.6 x_fwhm = 2.0 y_fwhm = 3.0 model = GaussianPRF(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm) yy, xx = np.mgrid[-5:6, -5:6] x_sigma = x_fwhm * gaussian_fwhm_to_sigma y_sigma = y_fwhm * gaussian_fwhm_to_sigma dx = xx - x_0 dy = yy - y_0 expected = (flux / 4.0 * ((erf((dx + 0.5) / (np.sqrt(2) * x_sigma)) - erf((dx - 0.5) / (np.sqrt(2) * x_sigma))) * (erf((dy + 0.5) / (np.sqrt(2) * y_sigma)) - erf((dy - 0.5) / (np.sqrt(2) * y_sigma))))) assert_allclose(model(xx, yy), expected) def _numerical_pixel_integral(model, xx, yy, n_sub=101): """ Integrate a model over the unit pixels centered at the input positions with the midpoint rule. """ offsets = (np.arange(n_sub) + 0.5) / n_sub - 0.5 xsub = xx[:, :, np.newaxis, np.newaxis] + offsets ysub = yy[:, :, np.newaxis, np.newaxis] + offsets[:, np.newaxis] return model(xsub, ysub).mean(axis=(2, 3)) class TestGaussianPRFRotated: @pytest.mark.parametrize('theta', [17.0, 30.0, 45.0, 90.0, 135.0, -20.0]) @pytest.mark.parametrize(('x_fwhm', 'y_fwhm'), [(1.0, 1.4), (0.6, 1.8), (3.0, 1.5)]) @pytest.mark.parametrize(('x_0', 'y_0'), [(0.37, -0.13), (0.5, 0.5), (0.5, 0.0), (0.0, 0.0)]) def test_matches_pixel_integral(self, theta, x_fwhm, y_fwhm, x_0, y_0): """ Test that the model is the integral of the rotated GaussianPSF over the axis-aligned pixels. The source positions include pixel corners and edges, where the standardized pixel-edge coordinates are exactly zero. """ params = {'flux': 3.2, 'x_0': x_0, 'y_0': y_0, 'x_fwhm': x_fwhm, 'y_fwhm': y_fwhm, 'theta': theta} yy, xx = np.mgrid[-6:7, -6:7] expected = _numerical_pixel_integral(GaussianPSF(**params), xx, yy) result = GaussianPRF(**params)(xx, yy) assert_allclose(result, expected, atol=5e-5 * expected.max()) @pytest.mark.parametrize('theta', [30.0, 45.0, 73.0]) def test_flux_conservation(self, theta): """ Test that an undersampled rotated model conserves flux at any subpixel position and is never negative. """ yy, xx = np.mgrid[-25:26, -25:26] for x_0, y_0 in [(0, 0), (0.5, 0), (0.5, 0.5), (0.37, -0.13)]: model = GaussianPRF(x_0=x_0, y_0=y_0, x_fwhm=1.0, y_fwhm=1.4, theta=theta) data = model(xx, yy) assert_allclose(data.sum(), 1.0, rtol=1e-12) assert np.all(data >= 0) def test_equal_widths(self): """ Test that the rotation has no effect for equal widths. """ yy, xx = np.mgrid[-5:6, -5:6] model1 = GaussianPRF(x_0=0.2, y_0=0.3, x_fwhm=2, y_fwhm=2, theta=37) model2 = CircularGaussianPRF(x_0=0.2, y_0=0.3, fwhm=2) assert_allclose(model1(xx, yy), model2(xx, yy), rtol=1e-12) def test_right_angle(self): """ Test that a rotation by 90 degrees swaps the widths. """ yy, xx = np.mgrid[-5:6, -5:6] model1 = GaussianPRF(x_0=0.2, y_0=0.3, x_fwhm=2, y_fwhm=3, theta=90) model2 = GaussianPRF(x_0=0.2, y_0=0.3, x_fwhm=3, y_fwhm=2, theta=0) assert_allclose(model1(xx, yy), model2(xx, yy), rtol=1e-12) def test_small_angle_continuity(self): """ Test that the values are continuous between the separable and rotated evaluations. """ yy, xx = np.mgrid[-5:6, -5:6] model1 = GaussianPRF(x_0=0.2, y_0=0.3, x_fwhm=2, y_fwhm=3, theta=0) model2 = GaussianPRF(x_0=0.2, y_0=0.3, x_fwhm=2, y_fwhm=3, theta=1e-9) assert_allclose(model1(xx, yy), model2(xx, yy), atol=1e-11) @pytest.mark.parametrize(('x_0', 'y_0'), [(0.37, -0.13), (4012.37, 3011.81)]) @pytest.mark.parametrize('layout', ['grid', 'flat', 'holes', 'offset']) def test_shared_corners(self, x_0, y_0, layout): """ Test that pixels on a pixel grid, whose shared corners are evaluated once, give the same values as pixels evaluated one at a time. The layouts are a 2D grid, a flattened grid, a shuffled list of pixels with some missing, and a grid offset by a fraction of a pixel. """ model = GaussianPRF(flux=3.2, x_0=x_0, y_0=y_0, x_fwhm=1.1, y_fwhm=2.3, theta=33.0) yy, xx = np.mgrid[-6:7, -8:9] xx = xx + np.floor(x_0) yy = yy + np.floor(y_0) if layout == 'flat': xx = xx.ravel() yy = yy.ravel() elif layout == 'holes': rng = np.random.default_rng(0) keep = rng.permutation(xx.size)[:150] xx = xx.ravel()[keep] yy = yy.ravel()[keep] elif layout == 'offset': xx = xx + 0.5 yy = yy + 0.25 result = model(xx, yy) expected = np.array([model(xval, yval) for xval, yval in zip(xx.ravel(), yy.ravel(), strict=True)]) assert result.shape == xx.shape assert_allclose(result.ravel(), expected, rtol=1e-12, atol=1e-15) assert expected.max() > 0.1 def test_not_on_pixel_grid(self): """ Test positions that are not on a grid with a spacing of one pixel, and positions too sparse to share corners. """ params = {'flux': 3.2, 'x_0': 0.37, 'y_0': -0.13, 'x_fwhm': 1.1, 'y_fwhm': 2.3, 'theta': 33.0} model = GaussianPRF(**params) yy, xx = np.mgrid[-6:7, -6:7] xx = 0.5 * xx yy = 0.5 * yy expected = _numerical_pixel_integral(GaussianPSF(**params), xx, yy) assert_allclose(model(xx, yy), expected, atol=5e-5 * expected.max()) yy, xx = np.mgrid[-6:7:4, -6:7:4] expected = _numerical_pixel_integral(GaussianPSF(**params), xx, yy) assert_allclose(model(xx, yy), expected, atol=5e-5 * expected.max()) @pytest.mark.parametrize('value', [np.nan, np.inf]) def test_non_finite_position(self, value): """ Test that a nonfinite position gives NaN for that pixel only. """ model = GaussianPRF(x_0=4.3, y_0=0.2, x_fwhm=1.1, y_fwhm=2.3, theta=33.0) xx = np.arange(10.0) yy = np.zeros(10) expected = model(xx, yy) xx[2] = value result = model(xx, yy) assert np.isnan(result[2]) assert_allclose(np.delete(result, 2), np.delete(expected, 2), rtol=1e-12) def test_model_set(self): """ Test that a model set with rotated and unrotated members gives the same values as the individual models. """ yy, xx = np.mgrid[-5:6, -5:6] thetas = [0.0, 30.0] model_set = GaussianPRF(flux=[1.0, 2.0], x_0=[0.2, 0.7], y_0=[0.3, -0.1], x_fwhm=[2.0, 2.0], y_fwhm=[3.0, 3.0], theta=thetas, n_models=2) result = model_set(xx, yy, model_set_axis=False) for index, theta in enumerate(thetas): model = GaussianPRF(flux=model_set.flux.value[index], x_0=model_set.x_0.value[index], y_0=model_set.y_0.value[index], x_fwhm=2.0, y_fwhm=3.0, theta=theta) assert_allclose(result[index], model(xx, yy), rtol=1e-12, atol=1e-15) def test_units(self): """ Test that a rotated model with units gives the same values as the model without units. """ yy, xx = np.mgrid[-5:6, -5:6] model1 = GaussianPRF(flux=3, x_0=0.2, y_0=0.3, x_fwhm=2, y_fwhm=3, theta=30) model2 = GaussianPRF(flux=3 * u.Jy, x_0=0.2 * u.pix, y_0=0.3 * u.pix, x_fwhm=2 * u.pix, y_fwhm=3 * u.pix, theta=30 * u.deg) result = model2(xx * u.pix, yy * u.pix) assert result.unit == u.Jy assert_allclose(result.value, model1(xx, yy), rtol=1e-12) 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('model_cls', [GaussianPSF, GaussianPRF]) def test_gaussian_bounding_box_theta_units(model_cls): """ Regression test that float theta (degrees) and Quantity theta give identical bounding boxes. """ m_float = model_cls(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3, theta=90) m_quant = model_cls(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3, theta=90 * u.deg) bbf = m_float.bounding_box bbq = m_quant.bounding_box for axis in ('x', 'y'): assert_allclose(bbf[axis].lower, bbq[axis].lower) assert_allclose(bbf[axis].upper, bbq[axis].upper) @pytest.mark.parametrize('model_cls', [GaussianPSF, GaussianPRF]) def test_gaussian_bounding_box_theta90_swaps(model_cls): """ Test that a 90 degree rotation swaps the x and y bounding box extents. """ m0 = model_cls(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) m90 = model_cls(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3, theta=90) bb0 = m0.bounding_box bb90 = m90.bounding_box assert_allclose(bb90['x'].upper, bb0['y'].upper) assert_allclose(bb90['y'].upper, bb0['x'].upper) @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))) def test_moffat_psf_beta_bound(): """ Test that the default lower bound of beta is strictly greater than 1. """ assert MoffatPSF().beta.bounds[0] > 1.0 def test_airydisk_psf_quantity_flux(): """ Test evaluating the Airy disk model with a flux Quantity. """ model = AiryDiskPSF(flux=71.4 * u.Jy, x_0=24.3, y_0=25.2, radius=2.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) assert isinstance(data, u.Quantity) assert data.unit == u.Jy assert_allclose(data.value.sum(), model.flux.value, rtol=0.015) def test_airydisk_psf_scalar_coords(): """ Test that the Airy disk model can be evaluated at a scalar coordinate, including at the peak where the radius is zero. """ model = AiryDiskPSF(flux=1.0, x_0=0.0, y_0=0.0, radius=2.0) peak = model.evaluate(0.0, 0.0, 1.0, 0.0, 0.0, 2.0) assert np.ndim(peak) == 0 normalization = (4.0 / np.pi) * (2.0 / model._rz) ** 2 assert_allclose(peak, 1.0 / normalization) assert_allclose(model(0.0, 0.0), peak) def test_moffat_psf_scalar_coords(): """ Test that the Moffat model can be evaluated with plain float parameters. """ model = MoffatPSF(flux=71.4, x_0=0.0, y_0=0.0, alpha=5.1, beta=3.2) value = model.evaluate(1.0, 2.0, 71.4, 0.0, 0.0, 5.1, 3.2) assert_allclose(value, model(1.0, 2.0)) @pytest.mark.parametrize('model', [ GaussianPSF(), CircularGaussianPSF(), GaussianPRF(), CircularGaussianPRF(), CircularGaussianSigmaPRF(), MoffatPSF(), AiryDiskPSF(), MoffatPRF(), AiryDiskPRF()]) def test_nan_propagation(model): """ Test that NaN input coordinates propagate to NaN output values. """ assert np.isnan(model(np.nan, 0.0)) result = model(np.array([np.nan, 0.0]), np.array([0.0, 0.0])) assert np.isnan(result[0]) assert np.isfinite(result[1]) @pytest.mark.parametrize('model', [ CircularGaussianPSF(), CircularGaussianPRF(), CircularGaussianSigmaPRF()]) def test_circular_gaussian_unit_mismatch(model): inputs_unit = {'x': u.m, 'y': u.s} outputs_unit = {'z': u.Jy} match = "Units of 'x' and 'y' inputs should match" with pytest.raises(u.UnitsError, match=match): model._parameter_units_for_data_units(inputs_unit, outputs_unit) @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))) def test_airydisk_flux_array_broadcast(): """ Test that an array-valued flux can have more dimensions than the input coordinates. """ flux = np.array([1.0, 2.0, 3.0]) peak = AiryDiskPSF()(0, 0) assert_allclose(AiryDiskPSF(flux=flux)(0, 0), flux * peak) result = AiryDiskPSF(flux=flux * u.Jy)(0, 0) assert result.unit == u.Jy assert_allclose(result.value, flux * peak) def test_airydisk_infinite_radial_distance(): """ Regression test that AiryDiskPSF evaluates to zero at infinite radial distance, matching the other models, while NaN inputs still propagate. """ model = AiryDiskPSF(flux=1, radius=2) assert model(np.inf, 0.0) == 0.0 assert np.isnan(model(np.nan, 0.0)) result = model(np.array([np.inf, 0.0]), np.array([0.0, 0.0])) assert result[0] == 0.0 assert result[1] > 0.0 PIXEL_INTEGRATED_CASES = [ (MoffatPRF, MoffatPSF, {'alpha': 0.9, 'beta': 2.5}), (MoffatPRF, MoffatPSF, {'alpha': 3.1, 'beta': 4.2}), (AiryDiskPRF, AiryDiskPSF, {'radius': 1.0}), (AiryDiskPRF, AiryDiskPSF, {'radius': 3.3}), ] class TestPixelIntegratedProfiles: """ Tests of the pixel-integrated Moffat and Airy disk models. """ @pytest.mark.parametrize(('prf_class', 'psf_class', 'shape_params'), PIXEL_INTEGRATED_CASES) @pytest.mark.parametrize(('x_0', 'y_0'), [(0.0, 0.0), (0.5, 0.5), (0.37, -0.13)]) def test_matches_pixel_integral(self, prf_class, psf_class, shape_params, x_0, y_0): """ Test that the model is the integral of the matching PSF model over the pixels. """ params = {'flux': 3.2, 'x_0': x_0, 'y_0': y_0, **shape_params} yy, xx = np.mgrid[-6:7, -6:7] expected = _numerical_pixel_integral(psf_class(**params), xx, yy, n_sub=151) result = prf_class(**params)(xx, yy) assert_allclose(result, expected, atol=5e-5 * expected.max()) @pytest.mark.parametrize(('prf_class', 'psf_class', 'shape_params'), PIXEL_INTEGRATED_CASES[::2]) def test_flux_conservation(self, prf_class, psf_class, shape_params): """ Test that an undersampled model conserves flux at any subpixel position, unlike the matching PSF model. """ yy, xx = np.mgrid[-30:31, -30:31] sums = {prf_class: [], psf_class: []} for x_0, y_0 in [(0, 0), (0.5, 0), (0.5, 0.5), (0.37, -0.13)]: for model_class, values in sums.items(): model = model_class(x_0=x_0, y_0=y_0, **shape_params) values.append(model(xx, yy).sum()) assert np.ptp(sums[prf_class]) < 1e-5 assert np.ptp(sums[psf_class]) > 0.1 assert_allclose(sums[prf_class], 1.0, atol=5e-3) @pytest.mark.parametrize(('model', 'params'), [ (MoffatPRF(), (71.4, 24.3, 25.2, 0.9, 2.5)), (MoffatPRF(), (71.4, 24.5, 24.5, 4.1, 3.2)), (AiryDiskPRF(), (71.4, 24.3, 25.2, 1.1)), (AiryDiskPRF(), (71.4, 24.0, 25.0, 4.3)), ]) def test_fit_deriv(self, model, params): yy, xx = np.mgrid[20:30, 19:29] derivs = model.fit_deriv(xx, yy, *params) assert len(derivs) == len(params) # The step and the absolute tolerance keep the roundoff error # of the central difference, which is the only difference where # a derivative is zero by symmetry, far below the tolerance step = 1e-5 for index in range(len(params)): params_hi = list(params) params_lo = list(params) params_hi[index] += step params_lo[index] -= step numerical = ((model.evaluate(xx, yy, *params_hi) - model.evaluate(xx, yy, *params_lo)) / (2.0 * step)) assert derivs[index].shape == xx.shape assert_allclose(derivs[index], numerical, rtol=1e-5, atol=1e-8) @pytest.mark.parametrize(('model', 'model_init', 'free'), [ (MoffatPRF(flux=71.4, x_0=12.3, y_0=11.8, alpha=1.1, beta=2.7), MoffatPRF(flux=60.0, x_0=12.0, y_0=12.0, alpha=1.5, beta=2.2), ('alpha', 'beta')), (AiryDiskPRF(flux=71.4, x_0=12.3, y_0=11.8, radius=1.3), AiryDiskPRF(flux=60.0, x_0=12.0, y_0=12.0, radius=1.6), ('radius',)), ]) def test_fit_free_shape(self, model, model_init, free): """ Test fitting an undersampled model with free shape parameters. """ yy, xx = np.mgrid[0:25, 0:25] data = model(xx, yy) model_init = model_init.copy() for name in free: getattr(model_init, name).fixed = False fit_model = TRFLSQFitter()(model_init, xx, yy, data) assert_allclose(fit_model.parameters, model.parameters, rtol=1e-6) @pytest.mark.parametrize(('prf_class', 'psf_class', 'shape_params'), PIXEL_INTEGRATED_CASES[::2]) def test_single_node(self, prf_class, psf_class, shape_params): """ Test that one quadrature node gives the value at the pixel center. """ yy, xx = np.mgrid[-5:6, -5:6] params = {'flux': 3.2, 'x_0': 0.2, 'y_0': -0.3, **shape_params} assert_allclose(prf_class(n_nodes=1, **params)(xx, yy), psf_class(**params)(xx, yy), rtol=1e-12) @pytest.mark.parametrize('model_class', [MoffatPRF, AiryDiskPRF]) def test_n_nodes(self, model_class): model = model_class(n_nodes=5) assert model.n_nodes == 5 assert model.copy().n_nodes == 5 assert model_class().n_nodes == 9 match = 'n_nodes must be a positive integer' for n_nodes in (0, -1, 2.5, True): with pytest.raises(ValueError, match=match): model_class(n_nodes=n_nodes) with pytest.raises(ValueError, match=match): model.n_nodes = n_nodes assert model.n_nodes == 5 model.n_nodes = np.int64(3) assert model.n_nodes == 3 assert isinstance(model.n_nodes, int) @pytest.mark.parametrize(('prf_class', 'psf_class', 'shape_params'), PIXEL_INTEGRATED_CASES[::2]) def test_model_set(self, prf_class, psf_class, shape_params): """ Test that a model set can be evaluated at scalar and array inputs, like the matching PSF model. """ params = {'flux': [1.0, 2.0], 'x_0': [0.2, 0.2], 'y_0': [-0.3, -0.3]} params.update({name: [value, value] for name, value in shape_params.items()}) model = prf_class(n_models=2, **params) single = prf_class(flux=1.0, x_0=0.2, y_0=-0.3, **shape_params) result = model(0, 0) assert result.shape == psf_class(n_models=2, **params)(0, 0).shape assert_allclose(result, single(0, 0) * np.array([1.0, 2.0])) xx = np.tile(np.arange(-2.0, 3.0), (2, 1)) yy = np.zeros(xx.shape) result = model(xx, yy) assert result.shape == (2, 5) assert_allclose(result[1], 2.0 * single(xx[0], yy[0])) @pytest.mark.parametrize(('prf_class', 'psf_class', 'shape_params'), PIXEL_INTEGRATED_CASES[::2]) def test_parameter_array_broadcast(self, prf_class, psf_class, shape_params): """ Test that a parameter array with more dimensions than the input coordinates broadcasts like the matching PSF model. """ flux = np.array([[1.0], [2.0]]) xx = np.arange(-1.0, 2.0) yy = np.zeros(3) result = prf_class(flux=flux, **shape_params)(xx, yy) expected = flux * prf_class(**shape_params)(xx, yy) assert result.shape == psf_class(flux=flux, **shape_params)(xx, yy).shape assert_allclose(result, expected) result = prf_class(flux=[1.0, 2.0, 3.0], **shape_params)(0, 0) assert_allclose(result, prf_class(**shape_params)(0, 0) * np.arange(1, 4)) @pytest.mark.parametrize(('model', 'params'), [ (MoffatPRF(), (71.4, 4.3, 5.2, 0.9, 2.5)), (AiryDiskPRF(), (71.4, 4.3, 5.2, 1.1)), ]) @pytest.mark.parametrize('coords', [ tuple(np.mgrid[0:10, 0:11][::-1]), (4.0, 5.0), (np.array(4.0), np.array(5.0)), ]) def test_node_rows(self, model, params, coords, monkeypatch): """ Test that evaluating one row of quadrature nodes at a time, as is done for large inputs, gives the same result as evaluating all of the nodes in one call. """ xx, yy = coords expected = model.evaluate(xx, yy, *params) expected_derivs = model.fit_deriv(xx, yy, *params) monkeypatch.setattr(functional_models, '_MAX_QUADRATURE_ELEMENTS', 0) assert_allclose(model.evaluate(xx, yy, *params), expected, rtol=1e-12) derivs = model.fit_deriv(xx, yy, *params) assert len(derivs) == len(expected_derivs) for deriv, expected_deriv in zip(derivs, expected_derivs, strict=True): assert_allclose(deriv, expected_deriv, rtol=1e-12, atol=1e-14) @pytest.mark.parametrize(('model', 'model_units'), [ (MoffatPRF(flux=3, x_0=0.2, y_0=0.3, alpha=1.2, beta=2.5), MoffatPRF(flux=3 * u.Jy, x_0=0.2 * u.pix, y_0=0.3 * u.pix, alpha=1.2 * u.pix, beta=2.5)), (AiryDiskPRF(flux=3, x_0=0.2, y_0=0.3, radius=1.2), AiryDiskPRF(flux=3 * u.Jy, x_0=0.2 * u.pix, y_0=0.3 * u.pix, radius=1.2 * u.pix)), ]) def test_node_rows_units(self, model, model_units, monkeypatch): """ Test evaluating one row of quadrature nodes at a time for inputs with units. """ yy, xx = np.mgrid[-5:6, -5:6] expected = model(xx, yy) monkeypatch.setattr(functional_models, '_MAX_QUADRATURE_ELEMENTS', 0) result = model_units(xx * u.pix, yy * u.pix) assert result.unit == u.Jy assert_allclose(result.value, expected, rtol=1e-12) @pytest.mark.parametrize(('prf_class', 'psf_class', 'shape_params'), PIXEL_INTEGRATED_CASES[::2]) def test_node_rows_model_set(self, prf_class, psf_class, shape_params, monkeypatch): """ Test evaluating one row of quadrature nodes at a time for a model set. """ params = {'flux': [1.0, 2.0], 'x_0': [0.2, 0.2], 'y_0': [-0.3, -0.3]} params.update({name: [value, value] for name, value in shape_params.items()}) model = prf_class(n_models=2, **params) xx = np.tile(np.arange(-2.0, 3.0), (2, 1)) yy = np.zeros(xx.shape) expected = model(xx, yy) expected_scalar = model(0, 0) assert expected_scalar.shape == psf_class(n_models=2, **params)(0, 0).shape monkeypatch.setattr(functional_models, '_MAX_QUADRATURE_ELEMENTS', 0) assert_allclose(model(xx, yy), expected, rtol=1e-12) assert_allclose(model(0, 0), expected_scalar, rtol=1e-12) @pytest.mark.parametrize(('model', 'model_units'), [ (MoffatPRF(flux=3, x_0=0.2, y_0=0.3, alpha=1.2, beta=2.5), MoffatPRF(flux=3 * u.Jy, x_0=0.2 * u.pix, y_0=0.3 * u.pix, alpha=1.2 * u.pix, beta=2.5)), (AiryDiskPRF(flux=3, x_0=0.2, y_0=0.3, radius=1.2), AiryDiskPRF(flux=3 * u.Jy, x_0=0.2 * u.pix, y_0=0.3 * u.pix, radius=1.2 * u.pix)), ]) def test_units(self, model, model_units): yy, xx = np.mgrid[-5:6, -5:6] result = model_units(xx * u.pix, yy * u.pix) assert result.unit == u.Jy assert_allclose(result.value, model(xx, yy), rtol=1e-12) @pytest.mark.parametrize('model', [MoffatPRF(alpha=1.2, beta=2.5), AiryDiskPRF(radius=1.2)]) def test_scalar_coords(self, model): yy, xx = np.mgrid[0:3, 0:3] assert_allclose(model(1.0, 2.0), model(xx, yy)[2, 1]) astropy-photutils-24be529/photutils/psf/tests/test_gridded_models.py000066400000000000000000001463151526452531300261650ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the gridded_models module. """ import copy import gc import os.path as op import pickle # nosec B403 import threading from concurrent.futures import ThreadPoolExecutor from itertools import product from pathlib import Path import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter 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.psf.tests.test_model_io import STDPSF_FILENAMES from photutils.segmentation import SourceCatalog, detect_sources def _reference_find_bounding_points(model, x, y): """ Reference implementation of the bounding-point lookup using the pre-fast-path ``numpy.searchsorted``/``numpy.where`` algorithm. This is used to verify that the optimized ``_find_bounding_points`` and ``_bounding_lookup`` produce equivalent results. """ xidx = np.searchsorted(model._xgrid, x) - 1 yidx = np.searchsorted(model._ygrid, y) - 1 xidx = np.clip(xidx, 0, len(model._xgrid) - 2) yidx = np.clip(yidx, 0, len(model._ygrid) - 2) x0, x1 = model._xgrid[xidx], model._xgrid[xidx + 1] y0, y1 = model._ygrid[yidx], model._ygrid[yidx + 1] xcoords, ycoords = model.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 _reference_bilinear_weights(xi, yi, grid_xy): """ Reference implementation of the bilinear weights using the pre-fast-path ``numpy.clip`` algorithm. """ x0, x1, y0, y1 = grid_xy xi = np.clip(xi, x0, x1) yi = np.clip(yi, y0, y1) norm = (x1 - x0) * (y1 - y0) return np.array([(x1 - xi) * (y1 - yi), (xi - x0) * (y1 - yi), (x1 - xi) * (yi - y0), (xi - x0) * (yi - y0)]) / norm def _reference_calc_model_values(model, x_0, y_0, xi, yi): """ Reference implementation of ``_calc_model_values`` using the pre-fast-path bounding-point and weight algorithms. """ grid_idx, grid_xy = _reference_find_bounding_points(model, x_0, y_0) interpolators = np.array([model._calc_interpolator(gidx) for gidx in grid_idx]) weights = _reference_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 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) # meta must store the sorted positions, not the input ordering assert isinstance(psfmodel.meta['grid_xypos'], np.ndarray) assert_equal(psfmodel.meta['grid_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_grid_shape(self, psfmodel): assert psfmodel.grid_shape == (4, 4) assert all(isinstance(value, int) for value in psfmodel.grid_shape) assert 'grid_shape' not in psfmodel.meta match = 'object has no setter' with pytest.raises(AttributeError, match=match): psfmodel.grid_shape = (2, 2) def test_grid_shape_rectangular(self): """ Test that grid_shape is in (ny, nx) order. """ xgrid = [0, 10, 20] ygrid = [0, 5, 15, 25] meta = {'grid_xypos': list(product(xgrid, ygrid)), 'oversampling': 1} data = np.ones((len(xgrid) * len(ygrid), 5, 5)) psfmodel = GriddedPSFModel(NDData(data, meta=meta)) assert psfmodel.grid_shape == (len(ygrid), len(xgrid)) 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_evaluate_scalar_coords(self, psfmodel): """ Test that evaluate accepts scalar coordinates when called directly. """ value = psfmodel.evaluate(0.5, 0.5, 1.0, 0.0, 0.0) assert np.isfinite(value) def test_stale_grid_shape_meta(self, psfmodel): """ Test that a stale user-supplied grid_shape key is dropped from the meta dictionary. """ meta = dict(psfmodel.meta) meta['grid_shape'] = (999, 999) model = GriddedPSFModel(NDData(psfmodel.data, meta=meta)) assert 'grid_shape' not in model.meta def test_str_grid_positions_truncated(self, psfmodel): """ Test that the grid positions are summarized in the string representation instead of being printed in full. """ model_str = str(psfmodel) assert 'Grid positions' in model_str assert '...' in model_str @pytest.mark.parametrize('ndim', [1, 2]) def test_evaluate_matches_splines(self, psfmodel, ndim): """ Test that evaluate and fit_deriv match the direct weighted sum of the bounding-plane splines and their derivative splines. """ rng = np.random.default_rng(0) x_0, y_0, flux = 71.3, 123.8, 2.5 # The points stay inside the ePSF footprint, where no # fill_value applies if ndim == 1: x = rng.uniform(x_0 - 11, x_0 + 11, 200) y = rng.uniform(y_0 - 11, y_0 + 11, 200) else: y, x = np.mgrid[-12:13, -12:13] + np.array([[[y_0]], [[x_0]]]) x = x + 0.37 y = y - 0.21 grid_idx, grid_xy = psfmodel._find_bounding_points(x_0, y_0) weights = psfmodel._calc_bilinear_weights(x_0, y_0, grid_xy) dw_dx, dw_dy = psfmodel._calc_bilinear_weight_derivs(x_0, y_0, grid_xy) xi = psfmodel.oversampling[1] * (x - x_0) + psfmodel.origin[0] yi = psfmodel.oversampling[0] * (y - y_0) + psfmodel.origin[1] value = 0.0 deriv_x = 0.0 deriv_y = 0.0 for gidx, w, dwx, dwy in zip(grid_idx, weights, dw_dx, dw_dy, strict=True): spline = psfmodel._calc_interpolator(int(gidx)) sval = spline(xi, yi, grid=False) value += w * sval deriv_x += (dwx * sval - w * psfmodel.oversampling[1] * spline.partial_derivative(1, 0)(xi, yi, grid=False)) deriv_y += (dwy * sval - w * psfmodel.oversampling[0] * spline.partial_derivative(0, 1)(xi, yi, grid=False)) evaluated = psfmodel.evaluate(x, y, flux, x_0, y_0) assert evaluated.shape == x.shape assert_allclose(evaluated, flux * value, rtol=1e-12, atol=1e-14) d_flux, d_x_0, d_y_0 = psfmodel.fit_deriv(x, y, flux, x_0, y_0) assert d_flux.shape == x.shape assert_allclose(d_flux, value, rtol=1e-12, atol=1e-14) assert_allclose(d_x_0, flux * deriv_x, rtol=1e-12, atol=1e-13) assert_allclose(d_y_0, flux * deriv_y, rtol=1e-12, atol=1e-13) def test_single_plane_matches_spline(self, psfmodel): """ Test that a single-plane model evaluates its one spline, with position derivatives from the spline shift only. """ meta = {'grid_xypos': [psfmodel.grid_xypos[0]], 'oversampling': psfmodel.oversampling} model = GriddedPSFModel(NDData(psfmodel.data[:1], meta=meta)) rng = np.random.default_rng(3) x_0, y_0, flux = 10.2, 20.7, 1.5 x = rng.uniform(x_0 - 10, x_0 + 10, 100) y = rng.uniform(y_0 - 10, y_0 + 10, 100) xi = model.oversampling[1] * (x - x_0) + model.origin[0] yi = model.oversampling[0] * (y - y_0) + model.origin[1] spline = model._calc_interpolator(0) sval = spline(xi, yi, grid=False) assert_allclose(model.evaluate(x, y, flux, x_0, y_0), flux * sval, rtol=1e-12, atol=1e-14) d_flux, d_x_0, d_y_0 = model.fit_deriv(x, y, flux, x_0, y_0) assert_allclose(d_flux, sval, rtol=1e-12, atol=1e-14) assert_allclose(d_x_0, -flux * model.oversampling[1] * spline.partial_derivative(1, 0)(xi, yi, grid=False), rtol=1e-12, atol=1e-13) assert_allclose(d_y_0, -flux * model.oversampling[0] * spline.partial_derivative(0, 1)(xi, yi, grid=False), rtol=1e-12, atol=1e-13) @pytest.mark.parametrize('fill_value', [0.0, None]) @pytest.mark.parametrize('shapes', [((5,), ()), ((1, 5), (5, 1)), ((3, 5), (5,))]) def test_broadcast_inputs(self, psfmodel, fill_value, shapes): """ Test that evaluate and fit_deriv broadcast x and y inputs of different shapes against each other. """ psfmodel.fill_value = fill_value rng = np.random.default_rng(0) params = (2.5, 71.3, 123.8) # Some of the points are outside the ePSF footprint x = params[1] + rng.uniform(-14.0, 14.0, shapes[0]) y = params[2] + rng.uniform(-14.0, 14.0, shapes[1]) xb, yb = (np.ascontiguousarray(arr) for arr in np.broadcast_arrays(x, y)) result = psfmodel.evaluate(x, y, *params) assert result.shape == xb.shape assert_equal(result, psfmodel.evaluate(xb, yb, *params)) for got, expected in zip(psfmodel.fit_deriv(x, y, *params), psfmodel.fit_deriv(xb, yb, *params), strict=True): assert got.shape == xb.shape assert_equal(got, expected) @pytest.mark.parametrize('dtype', [np.float32, np.int32, np.int64]) def test_grid_xypos_dtype(self, psfmodel, dtype): """ Test that the grid positions can have any real dtype and give the same results as float64 positions. """ meta = {'grid_xypos': psfmodel.grid_xypos.astype(dtype), 'oversampling': psfmodel.oversampling} model = GriddedPSFModel(NDData(psfmodel.data, meta=meta)) params = (2.5, 71.3, 123.8) y, x = np.mgrid[-3:4, -3:4] + np.array([[[params[2]]], [[params[1]]]]) assert_equal(model.evaluate(x, y, *params), psfmodel.evaluate(x, y, *params)) for got, expected in zip(model.fit_deriv(x, y, *params), psfmodel.fit_deriv(x, y, *params), strict=True): assert_equal(got, expected) 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): """ Test a grid containing a single ePSF built via the public constructor. """ meta = {'grid_xypos': [(100, 100)], 'oversampling': 1} model = GriddedPSFModel(NDData(np.ones((1, 5, 5)), meta=meta)) assert model.grid_shape == (1, 1) # The spline-interpolated value can be off by 1 ulp on some # platforms, so do not test for exact equality assert_allclose(model(0, 0), 1) assert model(100, 100) == 0 assert_allclose(model([0, 100], [0, 100]), [1, 0]) y, x = np.mgrid[0:10, 0:10] psf = model.evaluate(x=x, y=y, flux=100, x_0=4, y_0=6) assert psf.shape == (10, 10) _, y2, x2 = np.mgrid[0:10, 0:10, 0:10] match = 'x and y must be 1D or 2D' with pytest.raises(ValueError, match=match): model.evaluate(x=x2, y=y2, flux=100, x_0=4, y_0=6) 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_scalar_fastpath_caches(self, psfmodel): """ The scalar fast-path lookup caches should have the expected types and shapes. """ nx = len(psfmodel._xgrid) ny = len(psfmodel._ygrid) assert isinstance(psfmodel._xgrid_list, list) assert isinstance(psfmodel._ygrid_list, list) assert all(isinstance(val, float) for val in psfmodel._xgrid_list) assert all(isinstance(val, float) for val in psfmodel._ygrid_list) assert_allclose(psfmodel._xgrid_list, psfmodel._xgrid) assert_allclose(psfmodel._ygrid_list, psfmodel._ygrid) lookup = psfmodel._bounding_lookup assert lookup.shape == (nx - 1, ny - 1, 4) assert lookup.dtype == np.intp def test_bounding_lookup_table(self, psfmodel): """ Each entry of the precomputed lookup table should map a grid cell to the source indices of its four bounding ePSFs. """ xcoords, ycoords = psfmodel.grid_xypos.T lookup = psfmodel._bounding_lookup for ix in range(len(psfmodel._xgrid) - 1): for iy in range(len(psfmodel._ygrid) - 1): x0 = psfmodel._xgrid[ix] x1 = psfmodel._xgrid[ix + 1] y0 = psfmodel._ygrid[iy] y1 = psfmodel._ygrid[iy + 1] expected = [ np.where((xcoords == x0) & (ycoords == y0))[0][0], np.where((xcoords == x1) & (ycoords == y0))[0][0], np.where((xcoords == x0) & (ycoords == y1))[0][0], np.where((xcoords == x1) & (ycoords == y1))[0][0]] assert_equal(lookup[ix, iy], expected) def test_find_bounding_points_interior(self, psfmodel): """ For interior points (not on a grid line), the optimized lookup should match the reference algorithm exactly. """ for x_0, y_0 in ((20, 30), (100, 100), (180, 170), (45.5, 61.5)): grid_idx, grid_xy = psfmodel._find_bounding_points(x_0, y_0) ref_idx, ref_xy = _reference_find_bounding_points( psfmodel, x_0, y_0) assert_equal(grid_idx, ref_idx) assert_allclose(grid_xy, ref_xy) def test_find_bounding_points_out_of_bounds(self, psfmodel): """ Out-of-grid points should clamp to the nearest grid cell. """ # Below the grid -> first cell _, grid_xy = psfmodel._find_bounding_points(-10, -20) assert_allclose(grid_xy, (psfmodel._xgrid[0], psfmodel._xgrid[1], psfmodel._ygrid[0], psfmodel._ygrid[1])) # Above the grid -> last cell _, grid_xy = psfmodel._find_bounding_points(500, 500) assert_allclose(grid_xy, (psfmodel._xgrid[-2], psfmodel._xgrid[-1], psfmodel._ygrid[-2], psfmodel._ygrid[-1])) def test_bilinear_weights(self, psfmodel): """ Bilinear weights should sum to one, be nonnegative, and clamp out-of-cell coordinates to the cell bounds. """ grid_xy = np.array((0.0, 40.0, 0.0, 60.0)) # Interior point weights = psfmodel._calc_bilinear_weights(10.0, 15.0, grid_xy) assert_allclose(weights.sum(), 1.0) assert np.all(weights >= 0) # Exact lower-left corner -> one-hot on the lower-left point weights = psfmodel._calc_bilinear_weights(0.0, 0.0, grid_xy) assert_allclose(weights, (1.0, 0.0, 0.0, 0.0)) # Exact upper-right corner -> one-hot on the upper-right point weights = psfmodel._calc_bilinear_weights(40.0, 60.0, grid_xy) assert_allclose(weights, (0.0, 0.0, 0.0, 1.0)) # Out-of-cell coordinates are clamped to the cell bounds clamped = psfmodel._calc_bilinear_weights(-5.0, 80.0, grid_xy) edge = psfmodel._calc_bilinear_weights(0.0, 60.0, grid_xy) assert_allclose(clamped, edge) def test_origin(self, psfmodel): """ Test that the origin is set to the center of the ePSF images. """ ny, nx = psfmodel.data.shape[1:] assert psfmodel.origin.shape == (2,) assert_allclose(psfmodel.origin, ((nx - 1) / 2, (ny - 1) / 2)) def test_evaluate_matches_reference_algorithm(self, psfmodel): """ The optimized evaluation must produce the same result as the pre-fast-path reference algorithm for interior, on-grid, and out-of-bounds positions. """ y, x = np.mgrid[0:50, 0:50] positions = ((20, 30), (100, 100), (180, 170), (45.5, 61.5), (40, 60), (160, 140), (0, 0), (200, 200), (-10, -20), (500, 500)) for x_0, y_0 in positions: xi = psfmodel.oversampling[1] * (x.astype(float) - x_0) yi = psfmodel.oversampling[0] * (y.astype(float) - y_0) xi += psfmodel.origin[0] yi += psfmodel.origin[1] result = psfmodel._calc_model_values(x_0, y_0, xi, yi) expected = _reference_calc_model_values(psfmodel, x_0, y_0, xi, yi) assert_allclose(result, expected, rtol=1e-12, atol=1e-12) @pytest.mark.parametrize('oversampling', [4, (2, 3)]) @pytest.mark.parametrize('position', [(95.3, 87.6), (-30.0, 87.6), (95.3, 230.0)]) def test_fit_deriv(self, psfmodel, oversampling, position): """ Test the analytic derivatives against central finite differences of evaluate, for interior and outside-grid model positions and for symmetric and asymmetric oversampling. The grid planes are distinct, so this test also verifies the derivative term from the change of the bilinearly interpolated ePSF with the model position (omitting it gives errors of about 1e-3, far above the tolerances). """ meta = {'grid_xypos': psfmodel.grid_xypos, 'oversampling': oversampling} model = GriddedPSFModel(NDData(psfmodel.data, meta=meta)) flux = 3.0 x_0, y_0 = position x, y = np.meshgrid(np.linspace(x_0 - 8, x_0 + 8, 13), np.linspace(y_0 - 8, y_0 + 8, 13)) x = x.ravel() y = y.ravel() d_flux, d_x_0, d_y_0 = model.fit_deriv(x, y, flux, x_0, y_0) eps = 1e-6 def ev(f, a, b): return model.evaluate(x, y, f, a, b) num_flux = (ev(flux + eps, x_0, y_0) - ev(flux - eps, x_0, y_0)) / (2 * eps) num_x_0 = (ev(flux, x_0 + eps, y_0) - ev(flux, x_0 - eps, y_0)) / (2 * eps) num_y_0 = (ev(flux, x_0, y_0 + eps) - ev(flux, x_0, y_0 - eps)) / (2 * eps) assert_allclose(d_flux, num_flux, atol=1e-8) assert_allclose(d_x_0, num_x_0, atol=1e-7) assert_allclose(d_y_0, num_y_0, atol=1e-7) def test_fit_deriv_grid_corner(self, psfmodel): """ Test the derivatives with the model positioned exactly on a grid corner, where one bounding ePSF has zero weight and zero weight derivatives. """ x_0, y_0 = 40.0, 60.0 # grid corner x = np.linspace(x_0 - 8, x_0 + 8, 9) y = np.linspace(y_0 - 8, y_0 + 8, 9) derivs = psfmodel.fit_deriv(x, y, 2.0, x_0, y_0) for deriv in derivs: assert np.all(np.isfinite(deriv)) # The flux derivative is the unit-flux model value assert_allclose(derivs[0], psfmodel.evaluate(x, y, 1.0, x_0, y_0)) def test_fit_deriv_single_plane(self, psfmodel): """ Test the derivatives for a grid containing a single ePSF, which has no dependence on the model position through the bilinear weights. """ meta = {'grid_xypos': [psfmodel.grid_xypos[0]], 'oversampling': psfmodel.oversampling} model = GriddedPSFModel(NDData(psfmodel.data[:1], meta=meta)) flux, x_0, y_0 = 2.0, 10.3, -4.6 x, y = np.meshgrid(np.linspace(x_0 - 8, x_0 + 8, 9), np.linspace(y_0 - 8, y_0 + 8, 9)) x = x.ravel() y = y.ravel() d_flux, d_x_0, d_y_0 = model.fit_deriv(x, y, flux, x_0, y_0) eps = 1e-6 def ev(f, a, b): return model.evaluate(x, y, f, a, b) num_flux = (ev(flux + eps, x_0, y_0) - ev(flux - eps, x_0, y_0)) / (2 * eps) num_x_0 = (ev(flux, x_0 + eps, y_0) - ev(flux, x_0 - eps, y_0)) / (2 * eps) num_y_0 = (ev(flux, x_0, y_0 + eps) - ev(flux, x_0, y_0 - eps)) / (2 * eps) assert_allclose(d_flux, num_flux, atol=1e-8) assert_allclose(d_x_0, num_x_0, atol=1e-7) assert_allclose(d_y_0, num_y_0, atol=1e-7) def test_fit_deriv_out_of_bounds(self, psfmodel): """ Test the derivatives at positions that map outside the ePSF pixel grid. """ x_0, y_0 = 95.3, 87.6 x = np.array([x_0, x_0 - 100.0, x_0 + 100.0]) y = np.array([y_0, y_0, y_0]) derivs = psfmodel.fit_deriv(x, y, 1.0, x_0, y_0) # All derivatives must be zero outside the ePSF pixel grid for deriv in derivs: assert_equal(deriv[1:], 0.0) # The flux derivative is nonzero at the in-bounds peak position assert derivs[0][0] > 0.0 def test_fit_deriv_no_fill_value(self, psfmodel): """ Test the derivatives with fill_value=None, where a point outside the ePSF pixel grid takes the spline value at the nearest point on the grid edge. The ePSF then does not shift with the model position along an axis on which the point is outside the grid, and only the bilinear weights change. """ # Add a gradient so that the ePSFs are not flat at their edges yy, xx = np.mgrid[0:101, 0:101] data = psfmodel.data + 0.01 * xx + 0.02 * yy meta = {'grid_xypos': psfmodel.grid_xypos, 'oversampling': psfmodel.oversampling} model = GriddedPSFModel(NDData(data, meta=meta), fill_value=None) flux, x_0, y_0 = 2.0, 95.3, 87.6 # The points are outside the ePSF along x, along y, along both, # and along neither x = x_0 + np.array([14.0, 3.0, -20.0, 1.0]) y = y_0 + np.array([0.5, -15.0, 20.0, 2.0]) _, d_x_0, d_y_0 = model.fit_deriv(x, y, flux, x_0, y_0) eps = 1e-6 def ev(a, b): return model.evaluate(x, y, flux, a, b) num_x_0 = (ev(x_0 + eps, y_0) - ev(x_0 - eps, y_0)) / (2 * eps) num_y_0 = (ev(x_0, y_0 + eps) - ev(x_0, y_0 - eps)) / (2 * eps) assert_allclose(d_x_0, num_x_0, atol=1e-6) assert_allclose(d_y_0, num_y_0, atol=1e-6) def test_fit_deriv_scalar(self, psfmodel): """ Test that scalar inputs are promoted to 1D arrays, matching evaluate, and that inputs with more than 2 dimensions raise an error. """ x_0, y_0 = 95.3, 87.6 derivs = psfmodel.fit_deriv(x_0 + 1.5, y_0 - 2.5, 1.0, x_0, y_0) expected = psfmodel.fit_deriv(np.array([x_0 + 1.5]), np.array([y_0 - 2.5]), 1.0, x_0, y_0) for deriv, exp in zip(derivs, expected, strict=True): assert deriv.shape == (1,) assert_allclose(deriv, exp) # Size-1 array model parameters, as passed by the fitting # machinery, are converted to scalars derivs = psfmodel.fit_deriv(x_0 + 1.5, y_0 - 2.5, 1.0, np.array([x_0]), np.array([y_0])) for deriv, exp in zip(derivs, expected, strict=True): assert_allclose(deriv, exp) match = 'x and y must be 1D or 2D' with pytest.raises(ValueError, match=match): psfmodel.fit_deriv(np.ones((2, 2, 2)), np.ones((2, 2, 2)), 1.0, x_0, y_0) def test_fit_deriv_fitting(self, psfmodel): """ Test that fitting with the analytic Jacobian recovers the true parameters and matches the finite-difference approximation. """ yy, xx = np.mgrid[0:25, 0:25].astype(float) xx += 84.0 yy += 76.0 truth = psfmodel.copy() truth.flux, truth.x_0, truth.y_0 = 5.0, 96.4, 88.6 rng = np.random.default_rng(0) data = truth(xx, yy) + rng.normal(0.0, 0.005, xx.shape) assert GriddedPSFModel.fit_deriv is not None fit_params = [] for estimate_jacobian in (False, True): init = psfmodel.copy() init.flux, init.x_0, init.y_0 = 3.0, 96.0, 89.0 fitter = TRFLSQFitter() fit = fitter(init, xx.ravel(), yy.ravel(), data.ravel(), estimate_jacobian=estimate_jacobian) fit_params.append(fit.parameters) assert_allclose(fit_params[0], fit_params[1], rtol=1e-4) assert_allclose(fit_params[0], (5.0, 96.4, 88.6), rtol=1e-2) 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], [0, 1], [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) # An empty grid cannot form a rectangular grid meta = {'grid_xypos': [], 'oversampling': 4} nddata = NDData(np.ones((0, 5, 5)), meta=meta) match = 'grid_xypos must form a rectangular grid' with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) # Check that duplicate grid positions are rejected meta = {'grid_xypos': [(0, 0), (0, 1), (1, 0), (1, 1), (1, 1)], 'oversampling': 4} nddata = NDData(np.ones((5, 5, 5)), meta=meta) match = 'grid_xypos must not contain duplicate positions' 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], -80.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.grid_shape == psfmodel.grid_shape 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_non_finite_input(self): """ Test that nonfinite coordinates raise ValueError. """ groups = np.array([1, 1, 2]) match = 'x coordinates must be finite' with pytest.raises(ValueError, match=match): SourceGroups(np.array([1, np.nan, 3]), np.array([1, 2, 3]), groups) match = 'y coordinates must be finite' with pytest.raises(ValueError, match=match): SourceGroups(np.array([1, 2, 3]), np.array([1, np.inf, 3]), 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_color_kwarg(self, sample_groups): """ Test that a user-supplied color overrides the group colors. """ import matplotlib.pyplot as plt fig, ax = plt.subplots() result_ax = sample_groups['groups'].plot(radius=0.5, ax=ax, color='red') assert result_ax is ax plt.close(fig) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_label_kwargs_merge(self, sample_groups): """ Test that user label_kwargs are merged with the defaults. """ import matplotlib.pyplot as plt fig, ax = plt.subplots() sample_groups['groups'].plot(radius=0.5, ax=ax, label_groups=True, label_kwargs={'fontsize': 12}) assert len(ax.texts) == 3 for text in ax.texts: assert text.get_fontsize() == 12 assert text.get_horizontalalignment() == 'center' 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-24be529/photutils/psf/tests/test_image_models.py000066400000000000000000000753711526452531300256500ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the image_models module. """ import copy import pickle # nosec B403 from functools import cached_property import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.table import QTable from numpy.testing import assert_allclose, assert_equal from scipy.interpolate import RectBivariateSpline from photutils.datasets import make_model_image from photutils.psf import CircularGaussianPSF, ImagePSF from photutils.utils.exceptions import PhotutilsDeprecationWarning @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) @pytest.mark.parametrize('oversampling', [1, 2, (2, 3)]) @pytest.mark.parametrize('origin', [None, (11.0, 13.0)]) def test_fit_deriv(self, oversampling, origin): gaussian_psf = CircularGaussianPSF(flux=1, x_0=12, y_0=12, fwhm=3.5) yy, xx = np.mgrid[0:25, 0:25].astype(float) psf_data = gaussian_psf(xx, yy) flux, x_0, y_0 = 2.0, 12.3, 11.7 model = ImagePSF(psf_data, flux=flux, x_0=x_0, y_0=y_0, oversampling=oversampling, origin=origin) # Evaluation points covering the valid model domain, with a # margin so that the central differences below do not cross # the fill_value boundary ny, nx = psf_data.shape margin = 0.5 x_lo = x_0 - model.origin[0] / model.oversampling[1] + margin x_hi = (x_0 + (nx - 1 - model.origin[0]) / model.oversampling[1] - margin) y_lo = y_0 - model.origin[1] / model.oversampling[0] + margin y_hi = (y_0 + (ny - 1 - model.origin[1]) / model.oversampling[0] - margin) x, y = np.meshgrid(np.linspace(x_lo, x_hi, 15), np.linspace(y_lo, y_hi, 15)) x = x.ravel() y = y.ravel() d_flux, d_x_0, d_y_0 = model.fit_deriv(x, y, flux, x_0, y_0) eps = 1e-6 def ev(f, a, b): return model.evaluate(x, y, f, a, b) num_flux = (ev(flux + eps, x_0, y_0) - ev(flux - eps, x_0, y_0)) / (2 * eps) num_x_0 = (ev(flux, x_0 + eps, y_0) - ev(flux, x_0 - eps, y_0)) / (2 * eps) num_y_0 = (ev(flux, x_0, y_0 + eps) - ev(flux, x_0, y_0 - eps)) / (2 * eps) assert_allclose(d_flux, num_flux, atol=1e-8) assert_allclose(d_x_0, num_x_0, atol=1e-7) assert_allclose(d_y_0, num_y_0, atol=1e-7) def test_fit_deriv_out_of_bounds(self): gaussian_psf = CircularGaussianPSF(flux=1, x_0=5, y_0=5, fwhm=2.0) yy, xx = np.mgrid[0:11, 0:11].astype(float) psf_data = gaussian_psf(xx, yy) model = ImagePSF(psf_data, flux=1.0, x_0=5.0, y_0=5.0) # Include positions that map outside the input pixel grid x = np.array([5.0, -50.0, 100.0]) y = np.array([5.0, 5.0, 5.0]) derivs = model.fit_deriv(x, y, 1.0, 5.0, 5.0) # All derivatives must be zero outside the input pixel grid for deriv in derivs: assert_equal(deriv[1:], 0.0) # The flux derivative is nonzero at the in-bounds peak position assert derivs[0][0] > 0.0 def test_fit_deriv_no_fill_value(self): """ Test the derivatives with fill_value=None, where a point outside the image takes the spline value at the nearest point on the image edge. The model then does not change with the position along an axis on which the point is outside the image. """ yy, xx = np.mgrid[0:25, 0:25].astype(float) psf_data = (np.exp(-((xx - 12.0)**2 + (yy - 12.0)**2) / 200.0) * (1.0 + 0.05 * xx + 0.03 * yy)) flux, x_0, y_0 = 2.0, 0.3, -0.2 model = ImagePSF(psf_data, origin=(12.0, 12.0), fill_value=None) # The points are outside the image along x, along y, along # both, along neither, and along x on the other side x = np.array([14.0, 3.0, 20.0, 1.0, -15.0]) y = np.array([0.0, 15.0, 20.0, 2.0, 3.0]) d_flux, d_x_0, d_y_0 = model.fit_deriv(x, y, flux, x_0, y_0) assert np.all(d_flux > 0.0) assert np.all(d_x_0[[0, 2, 4]] == 0.0) assert np.all(d_y_0[[1, 2]] == 0.0) assert np.all(d_x_0[[1, 3]] != 0.0) assert np.all(d_y_0[[0, 3, 4]] != 0.0) eps = 1e-6 def ev(a, b): return model.evaluate(x, y, flux, a, b) num_x_0 = (ev(x_0 + eps, y_0) - ev(x_0 - eps, y_0)) / (2 * eps) num_y_0 = (ev(x_0, y_0 + eps) - ev(x_0, y_0 - eps)) / (2 * eps) assert_allclose(d_x_0, num_x_0, atol=1e-7) assert_allclose(d_y_0, num_y_0, atol=1e-7) def test_fit_deriv_scalar(self): gaussian_psf = CircularGaussianPSF(flux=1, x_0=5, y_0=5, fwhm=2.0) yy, xx = np.mgrid[0:11, 0:11].astype(float) psf_data = gaussian_psf(xx, yy) model = ImagePSF(psf_data, flux=1.0, x_0=5.0, y_0=5.0) # Scalar inputs are promoted to 1D arrays, matching evaluate derivs = model.fit_deriv(4.5, 5.5, 1.0, 5.0, 5.0) expected = model.fit_deriv(np.array([4.5]), np.array([5.5]), 1.0, 5.0, 5.0) for deriv, exp in zip(derivs, expected, strict=True): assert deriv.shape == (1,) assert_allclose(deriv, exp) # Scalar out-of-bounds inputs give zero derivatives derivs = model.fit_deriv(-50.0, 5.0, 1.0, 5.0, 5.0) for deriv in derivs: assert_equal(deriv, 0.0) def test_fit_deriv_fitting(self): """ Test that fitting with the analytic Jacobian recovers the true parameters and matches the finite-difference approximation. """ gaussian_psf = CircularGaussianPSF(flux=1, x_0=12, y_0=12, fwhm=3.5) yy, xx = np.mgrid[0:25, 0:25].astype(float) psf_data = gaussian_psf(xx, yy) truth = ImagePSF(psf_data, flux=250.0, x_0=11.6, y_0=12.4) rng = np.random.default_rng(0) data = truth(xx, yy) + rng.normal(0.0, 0.02, xx.shape) assert ImagePSF.fit_deriv is not None fit_params = [] for estimate_jacobian in (False, True): init = ImagePSF(psf_data, flux=200.0, x_0=12.0, y_0=12.0) fitter = TRFLSQFitter() fit = fitter(init, xx.ravel(), yy.ravel(), data.ravel(), estimate_jacobian=estimate_jacobian) fit_params.append(fit.parameters) assert_allclose(fit_params[0], fit_params[1], rtol=1e-5) assert_allclose(fit_params[0], (250.0, 11.6, 12.4), rtol=1e-2) 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_shape(self, image_psf): assert image_psf.shape == image_psf.data.shape assert image_psf.shape == (21, 21) def test_evaluate_scalar_coords(self, image_psf): """ Test that evaluate accepts scalar coordinates when called directly. """ value = image_psf.evaluate(0.5, 0.5, 1.0, 0.0, 0.0) assert np.isfinite(value) def test_data_setter(self): yy, xx = np.mgrid[0:25, 0:25] data1 = CircularGaussianPSF(x_0=12, y_0=12, fwhm=3.0)(xx, yy) data2 = CircularGaussianPSF(x_0=12, y_0=12, fwhm=8.0)(xx, yy) model = ImagePSF(data1, x_0=12, y_0=12) assert_allclose(model(12.0, 12.0), data1[12, 12]) # The cached interpolator must be discarded when data is set model.data = data2 assert_allclose(model(12.0, 12.0), data2[12, 12]) @pytest.mark.parametrize('ndim', [1, 2]) def test_evaluate_matches_spline(self, ndim): """ Test that evaluate and fit_deriv, computed by the compiled kernel, match the direct spline and derivative-spline evaluation, including the fill_value region outside the image. """ model = ImagePSF(self._gaussian_image(), flux=3.0, x_0=0.0, y_0=0.0, origin=(12.0, 12.0), oversampling=(2, 3)) x_0, y_0, flux = 30.7, 41.2, 2.5 rng = np.random.default_rng(0) if ndim == 1: x = rng.uniform(x_0 - 8, x_0 + 8, 300) y = rng.uniform(y_0 - 8, y_0 + 8, 300) else: y, x = np.mgrid[-7:8, -7:8] + np.array([[[y_0]], [[x_0]]]) x = x + 0.37 y = y - 0.21 xi = model.oversampling[1] * (x - x_0) + model.origin[0] yi = model.oversampling[0] * (y - y_0) + model.origin[1] spline = model.interpolator outside = ((xi < 0) | (xi > model.data.shape[1] - 1) | (yi < 0) | (yi > model.data.shape[0] - 1)) assert outside.any() # the fill_value branch is exercised expected = flux * spline(xi, yi, grid=False) expected[outside] = 0.0 evaluated = model.evaluate(x, y, flux, x_0, y_0) assert evaluated.shape == x.shape assert_allclose(evaluated, expected, rtol=1e-12, atol=1e-14) d_flux, d_x_0, d_y_0 = model.fit_deriv(x, y, flux, x_0, y_0) exp_flux = spline(xi, yi, grid=False) exp_x = (-flux * model.oversampling[1] * spline.partial_derivative(1, 0)(xi, yi, grid=False)) exp_y = (-flux * model.oversampling[0] * spline.partial_derivative(0, 1)(xi, yi, grid=False)) for arr in (exp_flux, exp_x, exp_y): arr[outside] = 0.0 assert_allclose(d_flux, exp_flux, rtol=1e-12, atol=1e-14) assert_allclose(d_x_0, exp_x, rtol=1e-12, atol=1e-13) assert_allclose(d_y_0, exp_y, rtol=1e-12, atol=1e-13) @pytest.mark.parametrize('fill_value', [0.0, None]) @pytest.mark.parametrize('shapes', [((5,), ()), ((1, 5), (5, 1)), ((3, 5), (5,))]) def test_broadcast_inputs(self, fill_value, shapes): """ Test that evaluate and fit_deriv broadcast x and y inputs of different shapes against each other. """ model = ImagePSF(self._gaussian_image(), origin=(12.0, 12.0), fill_value=fill_value) rng = np.random.default_rng(0) # Some of the points are outside the image x = rng.uniform(-14.0, 14.0, shapes[0]) y = rng.uniform(-14.0, 14.0, shapes[1]) xb, yb = (np.ascontiguousarray(arr) for arr in np.broadcast_arrays(x, y)) params = (2.0, 0.5, -0.25) result = model.evaluate(x, y, *params) assert result.shape == xb.shape assert_equal(result, model.evaluate(xb, yb, *params)) for got, expected in zip(model.fit_deriv(x, y, *params), model.fit_deriv(xb, yb, *params), strict=True): assert got.shape == xb.shape assert_equal(got, expected) @pytest.mark.parametrize('fill_value', [0.0, None]) def test_flux_units(self, fill_value): """ Test that a flux with units gives model values and position derivatives with the same units. """ data = self._gaussian_image() model = ImagePSF(data, flux=500.0 * u.Jy, origin=(12.0, 12.0), fill_value=fill_value) plain = ImagePSF(data, flux=500.0, origin=(12.0, 12.0), fill_value=fill_value) # Some of the points are outside the image x = np.linspace(-14.0, 14.0, 30) y = np.linspace(-10.0, 10.0, 30)[::-1] value = model(x, y) assert value.unit == u.Jy assert_equal(value.value, plain(x, y)) derivs = model.fit_deriv(x, y, 500.0 * u.Jy, 0.5, -0.25) expected = plain.fit_deriv(x, y, 500.0, 0.5, -0.25) assert_equal(derivs[0], expected[0]) for deriv, exp in zip(derivs[1:], expected[1:], strict=True): assert deriv.unit == u.Jy assert_equal(deriv.value, exp) def test_flux_units_model_image(self): """ Test that a model image can be made from fluxes with units. """ model = ImagePSF(self._gaussian_image()) params = QTable({'x_0': [20.0, 30.5], 'y_0': [20.0, 15.25], 'flux': [5.0, 3.0] * u.Jy}) image = make_model_image((40, 45), model, params, model_shape=(9, 9)) plain = QTable({'x_0': params['x_0'], 'y_0': params['y_0'], 'flux': params['flux'].value}) expected = make_model_image((40, 45), model, plain, model_shape=(9, 9)) assert image.unit == u.Jy assert_equal(image.value, expected) def test_flux_array(self): """ Test that a flux array is broadcast against the coordinates. """ model = ImagePSF(self._gaussian_image(), origin=(12.0, 12.0), fill_value=None) x = np.array([0.5]) y = np.array([-0.25]) fluxes = np.array([1.0, 2.0, 3.0]) single = model.evaluate(x, y, 1.0, 0.0, 0.0) assert_allclose(model.evaluate(x, y, fluxes, 0.0, 0.0), fluxes * single) derivs = model.fit_deriv(x, y, fluxes, 0.0, 0.0) expected = model.fit_deriv(x, y, 1.0, 0.0, 0.0) assert_allclose(derivs[1], fluxes * expected[1]) assert_allclose(derivs[2], fluxes * expected[2]) def test_custom_interpolator(self): """ Test that a subclass that overrides the interpolator is deprecated, and that it uses the interpolator and its partial_derivative method instead of the compiled kernel, giving the same results. """ data = self._gaussian_image() class WrappedSpline: # A custom interpolator with the RectBivariateSpline call # and partial_derivative interface def __init__(self, spline): self.spline = spline def __call__(self, xi, yi, grid=False): return self.spline(xi, yi, grid=grid) def partial_derivative(self, dx, dy): return self.spline.partial_derivative(dx, dy) class CustomImagePSF(ImagePSF): @cached_property def interpolator(self): x = np.arange(self.data.shape[1]) y = np.arange(self.data.shape[0]) return WrappedSpline(RectBivariateSpline(x, y, self.data.T, kx=3, ky=3, s=0)) model = ImagePSF(data, origin=(12.0, 12.0)) match = 'Overriding the ImagePSF.interpolator attribute' with pytest.warns(PhotutilsDeprecationWarning, match=match): custom = CustomImagePSF(data, origin=(12.0, 12.0)) x = np.linspace(-9.0, 9.0, 50) y = np.linspace(-8.0, 8.0, 50)[::-1] assert_allclose(custom.evaluate(x, y, 2.0, 0.5, -0.25), model.evaluate(x, y, 2.0, 0.5, -0.25), rtol=1e-12) for got, expected in zip(custom.fit_deriv(x, y, 2.0, 0.5, -0.25), model.fit_deriv(x, y, 2.0, 0.5, -0.25), strict=True): assert_allclose(got, expected, rtol=1e-12, atol=1e-13) # The interpolators are built ahead of fitting only for a # custom interpolator. The copy method builds the spline of # the other models. custom._precompute_interpolators() assert '_deriv_interpolators' in custom.__dict__ assert '_spline' not in custom.__dict__ model = ImagePSF(data, origin=(12.0, 12.0)) model._precompute_interpolators() assert '_deriv_interpolators' not in model.__dict__ assert '_spline' not in model.__dict__ model.copy() assert '_spline' in model.__dict__ @pytest.mark.parametrize('evaluate_first', [False, True]) @pytest.mark.parametrize('degree', [2, 3]) def test_assigned_interpolator(self, evaluate_first, degree): """ Test that assigning an interpolator to a model is deprecated, and that the model and its copies call the assigned interpolator, whether it is assigned before or after the model is first evaluated, until the data are set. """ data = self._gaussian_image() x = np.linspace(-9.0, 9.0, 50) y = np.linspace(-8.0, 8.0, 50)[::-1] params = (2.0, 0.5, -0.25) model = ImagePSF(data, origin=(12.0, 12.0)) reference = model.evaluate(x, y, *params) if not evaluate_first: model = ImagePSF(data, origin=(12.0, 12.0)) idx = np.arange(25) spline = RectBivariateSpline(idx, idx, 3.0 * data.T, kx=degree, ky=degree, s=0) match = 'Assigning a custom interpolator' with pytest.warns(PhotutilsDeprecationWarning, match=match): model.interpolator = spline xi = x - 0.5 + 12.0 yi = y + 0.25 + 12.0 expected = 2.0 * spline(xi, yi, grid=False) d_x = spline.partial_derivative(1, 0)(xi, yi, grid=False) for psf in (model, model.copy()): assert psf.interpolator is spline assert_allclose(psf.evaluate(x, y, *params), expected, rtol=1e-12) derivs = psf.fit_deriv(x, y, *params) assert_allclose(derivs[0], expected / 2.0, rtol=1e-12) assert_allclose(derivs[1], -2.0 * d_x, rtol=1e-12, atol=1e-13) # Setting the data discards the assigned interpolator model.data = data.copy() assert_allclose(model.evaluate(x, y, *params), reference, rtol=1e-12) def test_data_spline_public_interface(self, monkeypatch, public_spline): """ Test that the model evaluates the spline that it builds from the data with the compiled kernel using only the public interface of the scipy spline. """ data = self._gaussian_image() idx = np.arange(25) spline = RectBivariateSpline(idx, idx, data.T, kx=3, ky=3, s=0) monkeypatch.setattr('photutils.psf.image_models.RectBivariateSpline', public_spline) model = ImagePSF(data, origin=(12.0, 12.0)) flux, x_0, y_0 = 2.0, 0.5, -0.25 x = np.linspace(-9.0, 9.0, 50) y = np.linspace(-8.0, 8.0, 50)[::-1] xi = x - x_0 + 12.0 yi = y - y_0 + 12.0 assert_allclose(model.evaluate(x, y, flux, x_0, y_0), flux * spline(xi, yi, grid=False), rtol=1e-12, atol=1e-14) d_flux, d_x_0, _ = model.fit_deriv(x, y, flux, x_0, y_0) assert_allclose(d_flux, spline(xi, yi, grid=False), rtol=1e-12, atol=1e-14) assert_allclose(d_x_0, -flux * spline.partial_derivative(1, 0)( xi, yi, grid=False), rtol=1e-12, atol=1e-13) def test_data_setter_clears_spline(self): """ Test that setting new data discards the cached spline coefficients used by the compiled kernel. """ yy, xx = np.mgrid[0:25, 0:25] data1 = CircularGaussianPSF(x_0=12, y_0=12, fwhm=3.0)(xx, yy) data2 = CircularGaussianPSF(x_0=12, y_0=12, fwhm=8.0)(xx, yy) model = ImagePSF(data1, x_0=12, y_0=12) model.evaluate(np.array([12.0]), np.array([12.0]), 1.0, 12.0, 12.0) assert '_spline' in model.__dict__ model.data = data2 assert '_spline' not in model.__dict__ d_flux = model.fit_deriv(np.array([12.0]), np.array([12.0]), 1.0, 12.0, 12.0)[0] assert_allclose(d_flux, data2[12, 12]) @pytest.mark.parametrize('pickled', [False, True]) def test_deepcopy_pickle_keep_spline(self, pickled): """ Test that a deep copy and an unpickled copy of an evaluated model keep the cached spline and give the same values. """ model = ImagePSF(self._gaussian_image(), origin=(12.0, 12.0)) x = np.linspace(-9.0, 9.0, 50) y = np.linspace(-8.0, 8.0, 50)[::-1] params = (2.0, 0.5, -0.25) values = model.evaluate(x, y, *params) derivs = model.fit_deriv(x, y, *params) if pickled: new_model = pickle.loads( # noqa: S301 pickle.dumps(model)) # nosec B301 else: new_model = copy.deepcopy(model) assert '_spline' in new_model.__dict__ assert_equal(new_model.evaluate(x, y, *params), values) assert_equal(new_model.fit_deriv(x, y, *params), derivs) def test_data_setter_validation(self): model = ImagePSF(np.ones((10, 10))) match = 'Input data must be a 2D numpy array' with pytest.raises(TypeError, match=match): model.data = 42 with pytest.raises(ValueError, match=match): model.data = np.ones(10) match = 'The length of the x- and y-axes must both be at least 4' with pytest.raises(ValueError, match=match): model.data = np.ones((3, 4)) def test_data_setter_copy_independence(self, gaussian_psf): yy, xx = np.mgrid[0:25, 0:25] data1 = gaussian_psf(xx, yy) data2 = CircularGaussianPSF(x_0=12, y_0=12, fwhm=8.0)(xx, yy) model = ImagePSF(data1, x_0=12, y_0=12) value = model(12.0, 12.0) # populate the interpolator cache model_copy = model.copy() model_copy.data = data2 assert_allclose(model(12.0, 12.0), value) assert_allclose(model_copy(12.0, 12.0), data2[12, 12]) def test_oversampling_setter(self): model = ImagePSF(np.ones((10, 10))) model.oversampling = 4 assert_equal(model.oversampling, (4, 4)) match = 'oversampling must be > 0' with pytest.raises(ValueError, match=match): model.oversampling = -3 assert_equal(model.oversampling, (4, 4)) 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 @pytest.mark.parametrize('unpickled', [False, True]) def test_copies_share_spline(self, spline_builds, unpickled): """ Test that the copies of a model that was never evaluated share one spline instead of each building their own, including for a model that was unpickled (e.g., in a worker process), which never has a cached spline. """ model = ImagePSF(self._gaussian_image(), origin=(12.0, 12.0)) if unpickled: model = pickle.loads( # noqa: S301 pickle.dumps(model)) # nosec B301 x = np.linspace(-9.0, 9.0, 50) y = np.linspace(-8.0, 8.0, 50)[::-1] params = (2.0, 0.5, -0.25) copies = [model.copy() for _ in range(3)] for model_copy in copies: model_copy.evaluate(x, y, *params) model_copy.fit_deriv(x, y, *params) assert spline_builds.count == 1 assert_equal(copies[2].evaluate(x, y, *params), model.evaluate(x, y, *params)) assert spline_builds.count == 1 @staticmethod def _gaussian_image(): yy, xx = np.mgrid[0:25, 0:25] return np.exp(-((xx - 12.0)**2 + (yy - 12.0)**2) / 8.0) 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 @pytest.mark.parametrize('dtype', [np.float32, np.float64]) @pytest.mark.parametrize('oversampling', [1, 2]) def test_read_only_inputs(gaussian_psf, dtype, oversampling): """ Regression test that read-only (nonwritable) input arrays are accepted, are not modified, and give results identical to writable arrays. """ yy, xx = np.mgrid[-10:11, -10:11] psf_data = gaussian_psf(xx, yy).astype(dtype) y = np.linspace(-3, 3, 13) x = np.linspace(-2.5, 3.5, 13) arrays = (psf_data, x, y) originals = [arr.copy() for arr in arrays] def compute(): model = ImagePSF(psf_data, flux=10, x_0=0.3, y_0=-0.2, oversampling=oversampling) return (model(x, y), *model.fit_deriv(x, y, 10, 0.3, -0.2), model.copy()(x, y)) expected = compute() for arr in arrays: arr.setflags(write=False) result = compute() for res, exp in zip(result, expected, strict=True): assert_equal(res, exp) for arr, original in zip(arrays, originals, strict=True): assert_equal(arr, original) astropy-photutils-24be529/photutils/psf/tests/test_iterative.py000066400000000000000000001057461526452531300252170ustar00rootroot00000000000000# 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 Const2D, 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_image) from photutils.psf.flags import decode_psf_flags from photutils.utils.exceptions import (NoDetectionsWarning, PhotutilsDeprecationWarning) @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.ones(data.shape) 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 amplitude = 1.0 / (2.0 * np.pi * x_stddev * y_stddev) psf_model = Gaussian2D(amplitude=amplitude, x_mean=0, y_mean=0, x_stddev=x_stddev, y_stddev=y_stddev) * Const2D(1.0) psf_model.x_name = 'x_mean_0' psf_model.y_name = 'y_mean_0' psf_model.flux_name = 'amplitude_1' psf_model.amplitude_0.fixed = True psf_model.theta_0.fixed = True 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.ones(data.shape) 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_0', 'y_stddev_0') 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_0', 'y_stddev_0') 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_mode_all_local_bkg_init_flux(): """ Test that the initial flux of a source found in a later iteration of mode='all' has the local background subtracted over the aperture area. """ model = CircularGaussianPRF(fwhm=3) sources = Table({'x_0': [30.0, 34.5], 'y_0': [30.0, 30.0], 'flux': [5000.0, 800.0]}) data = make_model_image((61, 61), model, sources, model_shape=(31, 31)) data += make_noise_image(data.shape, mean=0, stddev=0.05, seed=0) bkg_level = 5.0 local_bkg_estimator = LocalBackground(12, 18, bkg_estimator=MMMBackground()) init_fluxes = [] for level, estimator in ((0.0, None), (bkg_level, local_bkg_estimator)): psfphot = IterativePSFPhotometry(model, (7, 7), finder=DAOStarFinder(20.0, 3.0), grouper=SourceGrouper(10), aperture_radius=5, local_bkg_estimator=estimator, mode='all', maxiters=2) phot = psfphot(data + level) assert_equal(phot['iter_detected'], [1, 2]) assert_allclose(phot['flux_fit'], sources['flux'], rtol=2e-3) init_params = psfphot.fit_results[1].init_params init_fluxes.append(init_params['flux_init'][1]) # The initial flux does not depend on a constant background level assert_allclose(init_fluxes[1], init_fluxes[0], rtol=0.01) def test_iterative_psf_photometry_mode_all_local_bkg(): """ Test mode='all' with a local_bkg_estimator, which is used to estimate the local background of sources detected in later iterations. """ 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) grouper = SourceGrouper(10) bkgstat = MMMBackground() local_bkg_estimator = LocalBackground(15, 20, bkg_estimator=bkgstat) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, sub_shape=psf_shape, mode='all', local_bkg_estimator=local_bkg_estimator, maxiters=3) phot = psfphot(data) assert 'local_bkg' in phot.colnames assert np.max(phot['iter_detected']) == 2 assert np.all(np.isfinite(phot['local_bkg'])) def make_one_shot_finder(x, y): """ Make a mock finder that detects one source on the first call and nothing on subsequent calls. """ state = {'called': False} def finder(data, *, mask=None): # noqa: ARG001 if state['called']: return None state['called'] = True tbl = QTable() tbl['x'] = [x] tbl['y'] = [y] return tbl return finder def test_mode_all_invalid_source(): """ Regression test for mode='all' when an earlier iteration produced a nonfinite fit (the invalid row is dropped and iter_detected must stay aligned). """ sources = QTable() sources['x_0'] = [30.0, 70.0, 50.0] sources['y_0'] = [30.0, 70.0, 50.0] sources['flux'] = [500.0, 300.0, 200.0] psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) data = make_model_image((101, 101), psf_model, sources, model_shape=(9, 9)) init_params = QTable() init_params['x'] = [30.0, 70.0, 1000.0] init_params['y'] = [30.0, 70.0, 1000.0] psfphot = IterativePSFPhotometry( psf_model, (5, 5), finder=make_one_shot_finder(50.0, 50.0), grouper=SourceGrouper(min_separation=5), aperture_radius=4, mode='all', maxiters=2, sub_shape=(5, 5)) phot = psfphot(data, init_params=init_params) assert len(phot) == 3 assert 'iter_detected' in phot.colnames assert_equal(phot['iter_detected'], [1, 1, 2]) assert_allclose(phot['flux_fit'], [500.0, 300.0, 200.0], rtol=0.01) def test_mode_all_carries_local_bkg(): """ Regression test that user-supplied local_bkg persists across mode='all' iterations. """ sources = QTable() sources['x_0'] = [30.0, 70.0, 50.0] sources['y_0'] = [30.0, 70.0, 50.0] sources['flux'] = [500.0, 300.0, 200.0] psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) data = make_model_image((101, 101), psf_model, sources, model_shape=(9, 9)) + 5.0 init_params = QTable() init_params['x'] = [30.0, 70.0] init_params['y'] = [30.0, 70.0] init_params['local_bkg'] = [5.0, 5.0] psfphot = IterativePSFPhotometry( psf_model, (5, 5), finder=make_one_shot_finder(50.0, 50.0), grouper=SourceGrouper(min_separation=5), aperture_radius=4, mode='all', maxiters=3, sub_shape=(5, 5)) phot = psfphot(data, init_params=init_params) assert_allclose(phot['local_bkg'][:2], [5.0, 5.0]) assert_allclose(phot['flux_fit'][:2], [500.0, 300.0], rtol=0.01) def test_image_methods_after_no_detections(test_data): """ Regression test that the image methods raise a clear error after a run in which no sources were detected. """ data, _, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) finder = DAOStarFinder(1e6, 2.0) psfphot = IterativePSFPhotometry(psf_model, (5, 5), finder=finder, aperture_radius=4) with pytest.warns(NoDetectionsWarning): result = psfphot(data) assert result is None match = 'No results available' with pytest.raises(ValueError, match=match): psfphot.make_model_image((25, 25)) with pytest.raises(ValueError, match=match): psfphot.make_residual_image(data) def test_empty_finder_table_stops_iteration(): """ Regression test that a callable finder returning a zero-row table stops the iteration cleanly. """ sources = QTable() sources['x_0'] = [30.0, 70.0] sources['y_0'] = [30.0, 70.0] sources['flux'] = [500.0, 300.0] psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) data = make_model_image((101, 101), psf_model, sources, model_shape=(9, 9)) def finder(data, *, mask=None): # noqa: ARG001 tbl = QTable() tbl['x'] = np.array([], dtype=float) tbl['y'] = np.array([], dtype=float) return tbl init_params = QTable() init_params['x'] = [30.0, 70.0] init_params['y'] = [30.0, 70.0] psfphot = IterativePSFPhotometry(psf_model, (5, 5), finder=finder, aperture_radius=4, maxiters=3, sub_shape=(5, 5)) phot = psfphot(data, init_params=init_params) assert len(phot) == 2 assert_equal(phot['iter_detected'], [1, 1]) 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 a strictly-positive scalar' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters='3') with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=True) 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) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=3.0) assert psfphot.maxiters == 3 assert isinstance(psfphot.maxiters, int) match = 'sub_shape must have an odd value for both axes' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, sub_shape=4) @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 len(phot_tbl) == 3 assert_equal(phot_tbl['iter_detected'], [1, 2, 3]) 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__}(') assert 'group_warning_threshold' in cls_repr assert 'n_threads=1' in cls_repr @pytest.mark.usefixtures('gil_disabled') def test_n_threads(test_data): """ Test that n_threads is passed through to the PSFPhotometry instance and gives the same results as a single thread. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) finder = DAOStarFinder(6.0, 2.0) phots = [] for n_threads in (1, 2): psfphot = IterativePSFPhotometry(psf_model, (5, 5), finder, aperture_radius=4, maxiters=2, n_threads=n_threads) assert psfphot._psfphot.n_threads == n_threads phots.append(psfphot(data, error=error)) for name in ('x_fit', 'y_fit', 'flux_fit', 'flags', 'iter_detected'): assert_equal(np.asarray(phots[0][name]), np.asarray(phots[1][name])) match = 'n_threads must be a positive integer' with pytest.raises(ValueError, match=match): IterativePSFPhotometry(psf_model, (5, 5), finder, aperture_radius=4, n_threads=0) 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_deprecated_shims(test_data): """ Test the deprecated keyword and positional-argument shims. """ data, error, _ = test_data model = CircularGaussianPRF(flux=1, fwhm=2.7) finder = DAOStarFinder(6.0, 2.0) match = "'localbkg_estimator' was deprecated in version 3.0" with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot = IterativePSFPhotometry(model, (5, 5), finder=finder, aperture_radius=4, localbkg_estimator=None) psfphot(data, error=error) match = "'include_localbkg' was deprecated in version 3.0" with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot.make_model_image((25, 25), include_localbkg=False) with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot.make_residual_image(data, include_localbkg=False) match = ("Passing 'return_bit_values' positionally to " "'decode_flags' is deprecated") with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot.decode_flags(True) # noqa: FBT003 def test_iterative_model_residual_image_nonfinite_localbkg(test_data): """ Test that make_model_image and make_residual_image handle nonfinite local background values correctly for IterativePSFPhotometry. When include_local_bkg=True and the local_bkg is nonfinite (NaN or inf), the nonfinite 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 nonfinite 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 nonfinite 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 nonfinite local_bkg, the model with and without # local_bkg should be identical (since nonfinite 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 nonfinite 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 nonfinite 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 # nonfinite 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 non_positive_flux flag) m2 = CircularGaussianPRF(flux=-50, x_0=5, y_0=5, fwhm=2) # Source 3: outside the image (will have the no_overlap 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 dictionary keyed by source id assert isinstance(decoded_flags, dict) assert len(decoded_flags) == len(results) assert list(decoded_flags) == list(results['id']) assert all(type(key) is int for key in decoded_flags) # Each value should be a list of strings for decoded in decoded_flags.values(): 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[1], list) # Check that the second source has the non_positive_flux flag assert 'non_positive_flux' in decoded_flags[2] # 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[3]) > 0 assert 'no_overlap' in decoded_flags[3] # Verify that decode_flags gives the same result as calling # decode_psf_flags directly direct_decoded = decode_psf_flags(results['flags']) assert list(decoded_flags.values()) == direct_decoded astropy-photutils-24be529/photutils/psf/tests/test_matching_deprecated.py000066400000000000000000000040541526452531300271630ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the deprecated photutils.psf.matching subpackage. """ import pytest from photutils import psf_matching from photutils.psf import matching as old_matching from photutils.utils.exceptions import PhotutilsDeprecationWarning DEPRECATION_MATCH = 'photutils.psf.matching is deprecated' @pytest.mark.parametrize('name', old_matching.__all__) def test_deprecated_attribute_access(name): """ Test that accessing each public name from the old location emits a deprecation warning and returns the object from the new location. """ with pytest.warns(PhotutilsDeprecationWarning, match=DEPRECATION_MATCH): obj = getattr(old_matching, name) assert obj is getattr(psf_matching, name) def test_deprecated_from_import(): """ Test that a from-import of a name public in photutils < 3.0 emits a deprecation warning. """ with pytest.warns(PhotutilsDeprecationWarning, match=DEPRECATION_MATCH): from photutils.psf.matching import resize_psf assert resize_psf is psf_matching.resize_psf def test_all_contains_old_public_names(): """ Test that every name public in photutils < 3.0 is still importable from the old location. """ old_public_names = ('create_matching_kernel', 'resize_psf', 'CosineBellWindow', 'HanningWindow', 'SplitCosineBellWindow', 'TopHatWindow', 'TukeyWindow') for name in old_public_names: assert name in old_matching.__all__ def test_invalid_attribute(): """ Test that an invalid attribute raises AttributeError without a deprecation warning. """ match = "module 'photutils.psf.matching' has no attribute 'invalid'" with pytest.raises(AttributeError, match=match): getattr(old_matching, 'invalid') # noqa: B009 def test_dir(): """ Test that dir() lists all public names. """ assert sorted(dir(old_matching)) == sorted(old_matching.__all__) assert 'resize_psf' in dir(old_matching) astropy-photutils-24be529/photutils/psf/tests/test_model_copy.py000066400000000000000000000107131526452531300253420ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the copy methods of the PSF image models. """ from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Const2D from numpy.testing import assert_allclose, assert_equal from photutils.psf import ImagePSF # The mutable containers that astropy keeps on a model instance and that # a model copy may share with the original model. They are filled when # the model is created and are only read afterward. ASTROPY_SHARED_ATTRIBUTES = frozenset({'_param_metrics', '_input_units_strict', '_input_units_allow_dimensionless'}) @pytest.fixture(name='image_psf') def fixture_image_psf(): yy, xx = np.mgrid[0:25, 0:25] data = np.exp(-((xx - 12.0)**2 + (yy - 12.0)**2) / 8.0) return ImagePSF(data, flux=1.0, x_0=0.0, y_0=0.0, origin=(12.0, 12.0)) @pytest.fixture(name='model_case', params=['ImagePSF', 'GriddedPSFModel']) def fixture_model_case(request): """ Fixture that returns a PSF image model and the range of the x and y positions of the sources to fit with it. """ if request.param == 'ImagePSF': return request.getfixturevalue('image_psf'), (8.0, 40.0) return request.getfixturevalue('psfmodel'), (20.0, 180.0) def test_copy_isolates_fit_state(model_case): """ Test that copies share the image data but not astropy's parameter array, constraints, and constraints cache, which the fitters update in place. """ model, _ = model_case copy1 = model.copy() copy2 = model.copy() assert copy1.data is model.data for name in ('_parameters', '_mconstraints', '_constraints_cache'): assert getattr(copy1, name) is not getattr(model, name), name assert getattr(copy1, name) is not getattr(copy2, name), name assert copy1._mconstraints == model._mconstraints for name in model.param_names: assert getattr(copy1, name) is not getattr(model, name) # A parameter passes its model to the parameter validator assert getattr(copy1, name).model is copy1 assert getattr(model, name).model is model # Any other mutable container that astropy keeps on the instance # and that the copies share must be known to be safe to share. This # fails if astropy adds new mutable state to the model instances. astropy_keys = set(Const2D().__dict__) shared = {key for key, val in model.__dict__.items() if key in astropy_keys and isinstance(val, (dict, list, set, np.ndarray)) and copy1.__dict__[key] is val} assert shared <= ASTROPY_SHARED_ATTRIBUTES, shared copy1.parameters = copy1.parameters + 10.0 copy1.x_0.bounds = (1.0, 3.0) copy1.flux.fixed = True copy1.tied # noqa: B018, fills the constraints cache assert_equal(copy2.parameters, model.parameters) assert copy2.x_0.bounds == model.x_0.bounds assert not copy2.flux.fixed assert not model.flux.fixed @pytest.mark.usefixtures('short_switch_interval') def test_concurrent_fits_of_copies(model_case): """ Test that copies fitted at the same time in several threads give the same results as fitting them one after the other. """ model, position_range = model_case rng = np.random.default_rng(0) n_fits = 24 positions = rng.uniform(*position_range, (n_fits, 2)) fluxes = rng.uniform(50.0, 500.0, n_fits) yy, xx = np.mgrid[0:7, 0:7] def fit(index): x0, y0 = positions[index] truth = model.copy() truth.flux = fluxes[index] truth.x_0 = x0 + 0.3 truth.y_0 = y0 - 0.2 x = xx + int(x0) - 3 y = yy + int(y0) - 3 data = truth(x, y) guess = model.copy() guess.flux = fluxes[index] * 0.8 guess.x_0 = x0 guess.y_0 = y0 guess.x_0.bounds = (x0 - 1.0, x0 + 1.0) guess.y_0.bounds = (y0 - 1.0, y0 + 1.0) fitter = TRFLSQFitter() fitted = fitter(guess, x, y, data, maxiter=50) return np.array([fitted.flux.value, fitted.x_0.value, fitted.y_0.value]) serial = np.array([fit(i) for i in range(n_fits)]) with ThreadPoolExecutor(max_workers=8) as executor: threaded = np.array(list(executor.map(fit, range(n_fits)))) assert_allclose(threaded, serial, rtol=1e-12) assert_allclose(serial[:, 0], fluxes, rtol=1e-6) astropy-photutils-24be529/photutils/psf/tests/test_model_helpers.py000066400000000000000000000574151526452531300260440ustar00rootroot00000000000000# 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.io import fits 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.units import Quantity from numpy.testing import assert_allclose, assert_equal from scipy.interpolate import RectBivariateSpline from scipy.special import erf from photutils.detection import find_peaks from photutils.psf import (EPSFBuilder, ImagePSF, extract_stars, grid_from_epsfs, make_epsf_from_psf, make_psf_model) from photutils.psf.model_helpers import _integrate_model, _InverseShift from photutils.utils.exceptions import PhotutilsDeprecationWarning def call_make_psf_model(*args, **kwargs): """ Call the deprecated ``make_psf_model`` and check its warning. """ match = 'make_psf_model was deprecated in version 3.1' with pytest.warns(PhotutilsDeprecationWarning, match=match): return make_psf_model(*args, **kwargs) 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 = call_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 = call_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 = call_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): call_make_psf_model(model, x_name='x_mean_0', y_name='y_mean') with pytest.raises(ValueError, match=match): call_make_psf_model(model, x_name='x_mean', y_name='y_mean_10') def test_make_psf_model_invalid_flux_name(): """ Test that an invalid flux_name raises a clear ValueError. """ match = 'parameter name not found in the input model' with pytest.raises(ValueError, match=match): call_make_psf_model(Moffat2D(), x_name='x_0', y_name='y_0', flux_name='invalid') model = Gaussian2D(1, 5, 5, 1, 1) * Const2D(1.0) with pytest.raises(ValueError, match=match): call_make_psf_model(model, x_name='x_mean_0', y_name='y_mean_0', flux_name='invalid') 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): call_make_psf_model(model, x_name='x_mean_0', y_name='y_mean_0', normalize=True) def test_make_psf_model_normalize_dx_dy(): """ Regression test that make_psf_model normalization works with dx != dy. """ gauss = Gaussian2D(1, 0, 0, 1, 1) psf = call_make_psf_model(gauss, x_name='x_mean', y_name='y_mean', dx=11, dy=21, subsample=10) yy, xx = np.mgrid[-10:11, -10:11] total = psf(xx, yy).sum() assert_allclose(total, 1.0, atol=1e-3) # The square default grid must still integrate a unit Gaussian # to 2 * pi model = Gaussian2D(1, 0, 0, 1, 1) integral = _integrate_model(model, x_name='x_mean', y_name='y_mean') assert_allclose(integral, 2.0 * np.pi, rtol=1e-6) 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 = call_make_psf_model(moffat.copy(), x_name='x_0', y_name='y_0', normalize=False) psfmod2 = call_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 url = ('https://github.com/astropy/photutils-datasets/raw/main/' 'data/hst_wfc3ir_f160w_simulated_starfield.fits') data = fits.getdata(url) # 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning): psf_grid = grid_from_epsfs(self.epsfs) # meta stores the positions sorted by y and then by x assert_equal(psf_grid.meta['grid_xypos'], [(0.0, 0.0), (1000.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0)]) assert_equal(psf_grid.meta['grid_xypos'], psf_grid.grid_xypos) # 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(PhotutilsDeprecationWarning): psf_grid = grid_from_epsfs(self.epsfs, grid_xypos=grid_xypos) assert_equal(psf_grid.meta['grid_xypos'], [(250.0, 250.0), (750.0, 250.0), (250.0, 750.0), (750.0, 750.0)]) assert_equal(psf_grid.meta['grid_xypos'], psf_grid.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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning): psf_grid = grid_from_epsfs(self.epsfs, meta=meta) for key in [*keys, 'extra_key']: assert key in psf_grid.meta assert_equal(psf_grid.meta['grid_xypos'], [(0.0, 0.0), (1000.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0)]) assert_equal(psf_grid.meta['oversampling'], [4, 4]) assert psf_grid.meta['fill_value'] == 0.0 def _sampled_gaussian(sigma, oversampling, size, *, midpoints=False): """ Make a Gaussian PSF sampled at the points of an oversampled grid and the matching pixel-integrated ePSF. The PSF is centered on a grid with an odd number of points. If ``midpoints`` is `True`, it is instead centered on a grid with an even number of points and the ePSF is at the points midway between the grid points. """ oversampling = np.broadcast_to(oversampling, 2) profiles = [] for factor in oversampling: n_points = size * factor + (size * factor + 1 + midpoints) % 2 offsets = (np.arange(n_points) - (n_points - 1) / 2) / factor sampled = np.exp(-offsets**2 / (2 * sigma**2)) if midpoints: offsets = offsets[:-1] + 0.5 / factor scale = np.sqrt(2) * sigma integrated = 0.5 * (erf((offsets + 0.5) / scale) - erf((offsets - 0.5) / scale)) profiles.append((sampled * factor / sampled.sum(), integrated)) psf = np.outer(profiles[0][0], profiles[1][0]) epsf = np.outer(profiles[0][1], profiles[1][1]) return psf, epsf class TestMakeEPSFFromPSF: @pytest.mark.parametrize(('oversampling', 'atol', 'sum_rtol'), [(1, 2e-2, 1e-3), (2, 3e-3, 1e-6), (4, 3e-4, 1e-6), (5, 3e-4, 1e-6), ((3, 4), 3e-4, 1e-6)]) def test_gaussian(self, oversampling, atol, sum_rtol): """ Test that the result matches the analytic pixel-integrated Gaussian and preserves the normalization. The accuracy is set by how well the spline through the samples represents the PSF, so it improves with the oversampling. """ psf, expected = _sampled_gaussian(0.8, oversampling, 15) result = make_epsf_from_psf(psf, oversampling=oversampling) assert result.shape == psf.shape assert_allclose(result.sum(), psf.sum(), rtol=sum_rtol) assert_allclose(result, expected, atol=atol * expected.max()) def test_spline_integral(self): """ Test the result against the integral of the bicubic spline, including the truncated windows at the image edges. """ rng = np.random.default_rng(0) data = rng.random((9, 12)) result = make_epsf_from_psf(data, oversampling=(2, 3)) yy = np.arange(9.0) xx = np.arange(12.0) spline = RectBivariateSpline(yy, xx, data, kx=3, ky=3, s=0) for idx_y, idx_x in [(0, 0), (4, 6), (8, 11), (1, 10)]: expected = spline.integral(max(idx_y - 1.0, 0), min(idx_y + 1.0, 8), max(idx_x - 1.5, 0), min(idx_x + 1.5, 11)) / 6 assert_allclose(result[idx_y, idx_x], expected, rtol=1e-10) @pytest.mark.parametrize('oversampling', [4, 5]) def test_flux_conservation(self, oversampling): """ Test that a model of an undersampled PSF conserves flux at any subpixel position only after the pixel integration. """ psf, _ = _sampled_gaussian(0.42, oversampling, 15) epsf = make_epsf_from_psf(psf, oversampling=oversampling) yy, xx = np.mgrid[-10:11, -10:11] offsets = [(0, 0), (0.5, 0), (0.5, 0.5), (0.37, -0.13)] sums = {} for name, data in (('psf', psf), ('epsf', epsf)): model = ImagePSF(data, oversampling=oversampling) sums[name] = [] for x_0, y_0 in offsets: model.x_0 = x_0 model.y_0 = y_0 sums[name].append(model(xx, yy).sum()) assert_allclose(sums['epsf'], 1.0, atol=2e-5) assert np.ptp(sums['psf']) > 0.2 def test_stack(self): """ Test that a 3D stack is integrated image by image. """ psf1, _ = _sampled_gaussian(0.6, 3, 9) psf2, _ = _sampled_gaussian(1.1, 3, 9) result = make_epsf_from_psf(np.array([psf1, psf2]), oversampling=3) assert result.shape == (2, *psf1.shape) for image, psf in zip(result, (psf1, psf2), strict=True): expected = make_epsf_from_psf(psf, oversampling=3) assert_allclose(image, expected, rtol=1e-12, atol=1e-15) @pytest.mark.parametrize('oversampling', [2, 4, (4, 2)]) def test_midpoints_gaussian(self, oversampling): """ Test that ``midpoints=True`` puts the center of a PSF that is centered on an even-sized image on the central grid point of the output. """ psf, expected = _sampled_gaussian(0.8, oversampling, 16, midpoints=True) assert psf.shape[0] % 2 == 0 assert psf.shape[1] % 2 == 0 result = make_epsf_from_psf(psf, oversampling=oversampling, midpoints=True) assert result.shape == (psf.shape[0] - 1, psf.shape[1] - 1) assert result.shape[0] % 2 == 1 assert result.shape[1] % 2 == 1 assert result.argmax() == result.size // 2 assert_allclose(result, result[::-1, ::-1], rtol=1e-10, atol=1e-15) assert_allclose(result.sum(), psf.sum(), rtol=1e-6) atol = 3e-3 if np.min(oversampling) == 2 else 3e-4 assert_allclose(result, expected, atol=atol * expected.max()) def test_midpoints_spline_integral(self): """ Test the ``midpoints=True`` result against the integral of the bicubic spline over the pixels centered between the grid points, including the truncated windows at the image edges. """ rng = np.random.default_rng(0) data = rng.random((9, 12)) result = make_epsf_from_psf(data, oversampling=(2, 3), midpoints=True) assert result.shape == (8, 11) yy = np.arange(9.0) xx = np.arange(12.0) spline = RectBivariateSpline(yy, xx, data, kx=3, ky=3, s=0) for idx_y, idx_x in [(0, 0), (4, 6), (7, 10), (1, 9)]: expected = spline.integral(max(idx_y - 0.5, 0), min(idx_y + 1.5, 8), max(idx_x - 1.0, 0), min(idx_x + 2.0, 11)) / 6 assert_allclose(result[idx_y, idx_x], expected, rtol=1e-10) def test_midpoints_stack(self): """ Test ``midpoints=True`` for a 3D stack. """ rng = np.random.default_rng(0) data = rng.random((3, 10, 8)) result = make_epsf_from_psf(data, oversampling=2, midpoints=True) assert result.shape == (3, 9, 7) for image, psf in zip(result, data, strict=True): expected = make_epsf_from_psf(psf, oversampling=2, midpoints=True) assert_allclose(image, expected, rtol=1e-12, atol=1e-15) def test_midpoints_model(self): """ Test that models made from the ePSFs on the two grids agree and that both conserve flux. """ oversampling = 4 offsets = (np.arange(80) - 39.5) / oversampling sampled = np.exp(-offsets**2 / (2 * 0.6**2)) sampled *= oversampling / sampled.sum() psf = np.outer(sampled, sampled) yy, xx = np.mgrid[-7:8, -7:8] images = [] for midpoints in (False, True): epsf = make_epsf_from_psf(psf, oversampling=oversampling, midpoints=midpoints) model = ImagePSF(epsf, oversampling=oversampling, x_0=0.3, y_0=-0.2) images.append(model(xx, yy)) assert_allclose(images[-1].sum(), 1.0, atol=2e-5) assert_allclose(images[0], images[1], atol=1e-3 * images[0].max()) def test_oversampling_larger_than_image(self): """ Test that every window covers the whole image when the oversampling is larger than the image. """ rng = np.random.default_rng(0) data = rng.random((9, 12)) result = make_epsf_from_psf(data, oversampling=50) spline = RectBivariateSpline(np.arange(9.0), np.arange(12.0), data, kx=3, ky=3, s=0) expected = spline.integral(0, 8, 0, 11) / 50**2 assert result.shape == data.shape assert_allclose(result, expected, rtol=1e-10) @pytest.mark.parametrize('midpoints', [False, True]) def test_input_unchanged(self, midpoints): psf, _ = _sampled_gaussian(0.8, 2, 9) psf_orig = psf.copy() make_epsf_from_psf(psf, oversampling=2, midpoints=midpoints) assert_equal(psf, psf_orig) def test_units_dropped(self): """ Test that the units of a Quantity input are dropped. """ psf, _ = _sampled_gaussian(0.8, 2, 9) result = make_epsf_from_psf(Quantity(psf, 'Jy'), oversampling=2) assert not isinstance(result, Quantity) assert_equal(result, make_epsf_from_psf(psf, oversampling=2)) def test_masked_input(self): """ Test that a masked array is allowed only if no values are masked. """ psf, _ = _sampled_gaussian(0.8, 2, 9) expected = make_epsf_from_psf(psf, oversampling=2) data = np.ma.MaskedArray(psf, mask=np.zeros(psf.shape, dtype=bool)) assert_equal(make_epsf_from_psf(data, oversampling=2), expected) data.mask[4, 4] = True match = 'data must not have masked values' with pytest.raises(ValueError, match=match): make_epsf_from_psf(data, oversampling=2) def test_invalid_inputs(self): match = 'data must be a 2D or 3D array' with pytest.raises(ValueError, match=match): make_epsf_from_psf(np.ones(10), oversampling=2) match = 'must both be at least 4' with pytest.raises(ValueError, match=match): make_epsf_from_psf(np.ones((3, 10)), oversampling=2) data = np.ones((10, 10)) data[4, 4] = np.nan match = 'All elements of data must be finite' with pytest.raises(ValueError, match=match): make_epsf_from_psf(data, oversampling=2) match = 'oversampling must be > 0' with pytest.raises(ValueError, match=match): make_epsf_from_psf(np.ones((10, 10)), oversampling=0) match = 'oversampling must have integer values' for oversampling in (2.5, 4.0): with pytest.raises(ValueError, match=match): make_epsf_from_psf(np.ones((10, 10)), oversampling=oversampling) astropy-photutils-24be529/photutils/psf/tests/test_model_io.py000066400000000000000000000467571526452531300250200ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model_io module. """ import io import os import os.path as op from itertools import product from pathlib import Path import numpy as np import pytest from astropy.io import fits, registry from numpy.testing import assert_allclose, assert_equal from photutils.psf import GriddedPSFModel, STDPSFGrid from photutils.psf.model_io import (_get_metadata, _has_fits_header_keys, _read_stdpsf, _split_wfc_uvis, is_stdpsf, is_webbpsf, stdpsf_reader) from photutils.utils.exceptions import PhotutilsDeprecationWarning # 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') def get_data_filename(filename): """ Get the full path of a test data file. Parameters ---------- filename : str The name of the file in the test data directory. Returns ------- result : str The full path of the test data file. """ return op.join(op.dirname(op.abspath(__file__)), 'data', filename) def encode_position(x, y): """ Encode an ``(x, y)`` grid position as a single unique value. This is used to fill each plane of a mock ePSF grid so that the grid position a plane was read back at can be verified. Parameters ---------- x, y : float The grid position. Returns ------- result : float The encoded position. """ return float(x) + 10000.0 * float(y) def make_webbpsf_file(filename, xgrid, ygrid, psf_shape=(8, 8), oversampling=4, keys_to_remove=(), data_2d=False): """ Write a mock WebbPSF gridded-ePSF FITS file. WebbPSF orders the ePSF planes with the y position varying fastest and records each position as a ``"(y, x)"`` string in the ``DET_YX{i}`` header keywords. Each plane is filled with its `encode_position` value. Parameters ---------- filename : str The output filename. xgrid, ygrid : array_like The x and y detector grid positions. psf_shape : tuple of int, optional The ``(ny, nx)`` shape of each ePSF image. oversampling : int, optional The oversampling factor written to the ``OVERSAMP`` keyword. keys_to_remove : tuple of str, optional FITS header keywords to omit from the output file. This is used to create invalid files. data_2d : bool, optional Whether to write the data as a single 2D array instead of a 3D array. This requires a single grid position. """ header = fits.Header() header['OVERSAMP'] = oversampling header['INSTRUME'] = 'NIRCam' header['DETECTOR'] = 'NRCA1' header['FILTER'] = 'F200W' planes = [] for index, (x, y) in enumerate(product(xgrid, ygrid)): planes.append(np.full(psf_shape, encode_position(x, y))) header[f'DET_YX{index}'] = ( f'({float(y)}, {float(x)})', f"The #{index} PSF's (y,x) detector pixel position") for key in keys_to_remove: del header[key] data = planes[0] if data_2d else np.array(planes) hdu = fits.PrimaryHDU(data, header=header) hdu.writeto(filename, overwrite=True) class TestSTDPSFReader: """ Tests for reading STDPSF FITS files. """ def test_read_stdpsf(self): """ Test STDPSF read for a single detector. """ filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') psfmodel = GriddedPSFModel.read(filename) assert psfmodel.data.shape[0] == len(psfmodel.meta['grid_xypos']) assert isinstance(psfmodel.meta['grid_xypos'], np.ndarray) assert_equal(psfmodel.oversampling, [4, 4]) assert_equal(psfmodel.meta['oversampling'], psfmodel.oversampling) def test_read_stdpsf_path(self): """ Test STDPSF read with a pathlib.Path. The reader is called directly because the astropy unified-I/O registry converts a path-like input to a string before dispatching to the reader. """ filename = Path(get_data_filename('STDPSF_NRCA1_F150W_mock.fits')) psfmodel = stdpsf_reader(filename) assert psfmodel.data.shape[0] == len(psfmodel.meta['grid_xypos']) assert_equal(psfmodel.oversampling, [4, 4]) assert psfmodel.meta['STDPSF'] == str(filename) psfmodel2 = GriddedPSFModel.read(filename, format='stdpsf') assert_equal(psfmodel2.data, psfmodel.data) @pytest.mark.parametrize('filename', STDPSF_FILENAMES[1:]) @pytest.mark.parametrize('detector_id', [1, 2]) def test_read_stdpsf_multi_detector(self, filename, detector_id): """ Test STDPSF read for multiple detectors. """ filename = get_data_filename(filename) psfmodel = GriddedPSFModel.read(filename, detector_id=detector_id, format='stdpsf') assert psfmodel.data.shape[0] == len(psfmodel.meta['grid_xypos']) assert_equal(psfmodel.oversampling, [4, 4]) assert_equal(psfmodel.meta['oversampling'], psfmodel.oversampling) # Test format auto-detect psfmodel = GriddedPSFModel.read(filename, detector_id=detector_id) assert psfmodel.data.shape[0] == len(psfmodel.meta['grid_xypos']) match = 'detector_id must be specified' with pytest.raises(ValueError, match=match): GriddedPSFModel.read(filename, detector_id=None) match = 'detector_id must be ' with pytest.raises(ValueError, match=match): GriddedPSFModel.read(filename, detector_id=-1) def test_read_stdpsf_hdulist(self): """ Test that an open HDUList is accepted. """ filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') with fits.open(filename) as hdulist: grid_data = _read_stdpsf(hdulist) assert grid_data['data'].shape[0] == grid_data['n_psfs'] def test_read_stdpsf_fileobj(self): """ Test that an open file object is accepted. """ filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') with io.FileIO(filename) as fileobj: grid_data = _read_stdpsf(fileobj) assert grid_data['data'].shape[0] == grid_data['n_psfs'] def test_stdpsf_reader_hdulist(self): """ Test that the full STDPSF readers accept an open HDUList. """ filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') with fits.open(filename) as hdulist: model = stdpsf_reader(hdulist) assert isinstance(model, GriddedPSFModel) assert model.meta['instrument'] == 'JWST/NIRCam' assert model.meta['detector'] == 'A1' with fits.open(filename) as hdulist: grid = STDPSFGrid(hdulist) assert grid.data.ndim == 3 def test_read_stdpsf_buffered_reader(self): """ Test that a buffered binary file object is accepted. """ filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') with open(filename, 'rb') as fileobj: grid_data = _read_stdpsf(fileobj) assert grid_data['data'].shape[0] == grid_data['n_psfs'] def test_read_corrupt_fits(self, tmp_path): """ Test that a corrupt file with a FITS extension yields the clean registry identification error, not an OSError. """ filename = str(tmp_path / 'bad.fits') with open(filename, 'w') as f: f.write('This is not a FITS file.') match = 'Format could not be identified' with pytest.raises(registry.IORegistryError, match=match): GriddedPSFModel.read(filename) def test_read_stdpsf_not_fits(self): match = 'This interface supports only FITS files' with pytest.raises(TypeError, match=match): _read_stdpsf('filename.txt') with pytest.raises(TypeError, match=match): _read_stdpsf(42) def test_stdpsf_reader_positional_deprecation(self): """ Test that passing detector_id positionally warns. """ filename = get_data_filename('STDPSF_WFPC2_F814W_mock.fits') match = "'detector_id'" with pytest.warns(PhotutilsDeprecationWarning, match=match): model = stdpsf_reader(filename, 1) assert isinstance(model, GriddedPSFModel) def test_split_wfc_uvis_no_mutation(self): """ Test that repeated detector splits do not mutate the grid data. """ filename = get_data_filename('STDPSF_ACSWFC_F814W_mock.fits') grid_data = _read_stdpsf(filename) ygrid_orig = grid_data['ygrid'].copy() _split_wfc_uvis(grid_data, 2) assert_equal(grid_data['ygrid'], ygrid_orig) def test_read_stdpsf_missing_keys(self, tmp_path): filename = str(tmp_path / 'bad_stdpsf.fits') fits.PrimaryHDU(np.ones((4, 5, 5))).writeto(filename) match = 'Invalid STDPSF FITS file' with pytest.raises(ValueError, match=match): _read_stdpsf(filename) def test_read_stdpsf_unknown_grid_keys(self, tmp_path): header = fits.Header() header['NXPSFS'] = 2 header['NYPSFS'] = 2 filename = str(tmp_path / 'unknown_stdpsf.fits') fits.PrimaryHDU(np.ones((4, 5, 5)), header=header).writeto(filename) match = 'Unknown STDPSF FITS file' with pytest.raises(ValueError, match=match): _read_stdpsf(filename) class TestWebbPSFReader: """ Tests for reading WebbPSF FITS files. """ @pytest.mark.parametrize('filename', WEBBPSF_FILENAMES) def test_read_webbpsf(self, filename): filename = get_data_filename(filename) psfmodel = GriddedPSFModel.read(filename, format='webbpsf') assert psfmodel.data.shape[0] == len(psfmodel.meta['grid_xypos']) assert_equal(psfmodel.oversampling, [4, 4]) assert_equal(psfmodel.meta['oversampling'], psfmodel.oversampling) # Test format auto-detect psfmodel = GriddedPSFModel.read(filename) assert psfmodel.data.shape[0] == len(psfmodel.meta['grid_xypos']) def test_read_webbpsf_asymmetric_grid(self, tmp_path): """ Test that each ePSF plane of a WebbPSF file is placed at the detector position given by its DET_YX header keyword. The grid is deliberately nonsquare so that an x/y transpose cannot go unnoticed. """ xgrid = (0.0, 1000.0, 2047.0) ygrid = (0.0, 500.0, 1000.0, 1500.0, 2047.0) filename = str(tmp_path / 'webbpsf_asymmetric_mock.fits') make_webbpsf_file(filename, xgrid, ygrid) psfmodel = GriddedPSFModel.read(filename, format='webbpsf') assert_equal(np.unique(psfmodel.grid_xypos[:, 0]), xgrid) assert_equal(np.unique(psfmodel.grid_xypos[:, 1]), ygrid) assert psfmodel.grid_shape == (len(ygrid), len(xgrid)) for (x, y), plane in zip(psfmodel.grid_xypos, psfmodel.data, strict=True): assert_allclose(plane, encode_position(x, y)) def test_read_webbpsf_key_order(self, tmp_path): """ Test that the DET_YX keywords are ordered by their numeric index and not by their string ordering (e.g., DET_YX10 must follow DET_YX9). """ xgrid = (0.0, 1000.0) ygrid = (0.0, 400.0, 800.0, 1200.0, 1600.0, 2047.0) filename = str(tmp_path / 'webbpsf_key_order_mock.fits') make_webbpsf_file(filename, xgrid, ygrid) psfmodel = GriddedPSFModel.read(filename, format='webbpsf') assert len(psfmodel.grid_xypos) == 12 for (x, y), plane in zip(psfmodel.grid_xypos, psfmodel.data, strict=True): assert_allclose(plane, encode_position(x, y)) def test_read_webbpsf_2d(self, tmp_path): """ Test that a WebbPSF file containing a single 2D PSF is read as a (1, ny, nx) grid. """ filename = str(tmp_path / 'webbpsf_2d_mock.fits') make_webbpsf_file(filename, (100.0,), (200.0,), data_2d=True) psfmodel = GriddedPSFModel.read(filename, format='webbpsf') assert psfmodel.data.shape == (1, 8, 8) assert_equal(psfmodel.grid_xypos, [(100.0, 200.0)]) def test_read_webbpsf_missing_det_yx(self, tmp_path): filename = str(tmp_path / 'webbpsf_no_det_yx.fits') make_webbpsf_file(filename, (0.0, 10.0), (0.0, 10.0), keys_to_remove=('DET_YX0', 'DET_YX1', 'DET_YX2', 'DET_YX3')) match = 'Missing "DET_YX' with pytest.raises(ValueError, match=match): GriddedPSFModel.read(filename, format='webbpsf') def test_read_webbpsf_missing_oversamp(self, tmp_path): filename = str(tmp_path / 'webbpsf_no_oversamp.fits') make_webbpsf_file(filename, (0.0, 10.0), (0.0, 10.0), keys_to_remove=('OVERSAMP',)) match = 'Missing "OVERSAMP" header key' with pytest.raises(ValueError, match=match): GriddedPSFModel.read(filename, format='webbpsf') class TestGetMetadata: """ Tests for parsing metadata from the STDPSF filename. """ @pytest.mark.parametrize( ('filename', 'instrument', 'detector', 'filter_name'), [('STDPSF_ACSHRC_F814W.fits', 'HST/ACS', 'HRC', 'F814W'), ('STDPSF_MIRI_F560W.fits.gz', 'JWST/MIRI', 'MIRIM', 'F560W'), ('STDPSF_NRCA1_F150W_mock.fits', 'JWST/NIRCam', 'A1', 'F150W'), ('/a/b/STDPSF_WFC3IR_F160W.fits', 'HST/WFC3', 'IR', 'F160W')]) @pytest.mark.parametrize('detector_id', [None, 1]) def test_filename_fields(self, filename, instrument, detector, filter_name, detector_id): """ Test that the metadata of a single-detector file does not depend on ``detector_id``. """ meta = _get_metadata(filename, detector_id) assert meta['STDPSF'] == filename assert meta['instrument'] == instrument assert meta['detector'] == detector assert meta['filter'] == filter_name def test_unknown_detector_no_detector_id(self): """ Test that an unrecognized detector field is kept as is when no ``detector_id`` is input. """ meta = _get_metadata('STDPSF_BADDET_F814W.fits', None) assert meta['detector'] == 'BADDET' assert 'instrument' not in meta def test_fileobj(self, tmp_path): path = tmp_path / 'STDPSF_ACSHRC_F606W.fits' path.write_bytes(b'') with io.FileIO(str(path)) as fileobj: meta = _get_metadata(fileobj, None) assert meta['detector'] == 'HRC' assert meta['filter'] == 'F606W' @pytest.mark.parametrize('filename', ['abcdef123456', 'a_b', 'a_b_c_d_e']) def test_unparsable_filename(self, filename): # Test a cache filename from astropy download_file assert _get_metadata(filename, None) is None def test_fd_fileobj(self): """ Test a file object created from a file descriptor, whose name attribute is an int instead of a string. """ filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') fd = os.open(filename, os.O_RDONLY) with io.FileIO(fd) as fileobj: assert _get_metadata(fileobj, None) is None @pytest.mark.parametrize(('detector_id', 'detector'), [(1, 'PC'), (2, 'WF2'), (3, 'WF3'), (4, 'WF4')]) def test_wfpc2_detectors(self, detector_id, detector): meta = _get_metadata('STDPSF_WFPC2_F814W.fits', detector_id) assert meta['instrument'] == 'HST/WFPC2' assert meta['detector'] == detector @pytest.mark.parametrize(('detector_id', 'detector'), [(1, 'WFC2'), (2, 'WFC1')]) def test_wfc_chips(self, detector_id, detector): meta = _get_metadata('STDPSF_ACSWFC_F814W.fits', detector_id) assert meta['instrument'] == 'HST/ACS' assert meta['detector'] == detector def test_nrcsw_detectors(self): detectors = ['A1', 'A2', 'A3', 'A4', 'B1', 'B2', 'B3', 'B4'] for detector_id, detector in enumerate(detectors, start=1): meta = _get_metadata('STDPSF_NRCSW_F150W.fits', detector_id) assert meta['instrument'] == 'JWST/NIRCam' assert meta['detector'] == detector def test_unknown_detector(self): match = 'Unknown detector BADDET' with pytest.raises(ValueError, match=match): _get_metadata('STDPSF_BADDET_F814W.fits', 1) def test_detector_map_not_mutated(self): """ Repeated calls must not corrupt the module-level lookup table. """ first = _get_metadata('STDPSF_ACSWFC_F814W.fits', 1)['detector'] _get_metadata('STDPSF_ACSWFC_F814W.fits', 2) third = _get_metadata('STDPSF_ACSWFC_F814W.fits', 1)['detector'] assert first == third == 'WFC2' class TestFormatIdentifiers: """ Tests for the unified-I/O format identifier functions. """ def test_is_stdpsf(self): filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') assert is_stdpsf('read', filename, None) assert not is_webbpsf('read', filename, None) def test_is_webbpsf(self): filename = get_data_filename(WEBBPSF_FILENAMES[0]) assert is_webbpsf('read', filename, None) assert not is_stdpsf('read', filename, None) def test_path_input(self): filename = Path(get_data_filename('STDPSF_NRCA1_F150W_mock.fits')) assert is_stdpsf('read', filename, None) assert not is_webbpsf('read', filename, None) @pytest.mark.parametrize('filepath', [None, 'filename.txt', Path('filename.txt')]) def test_not_a_fits_file(self, filepath): assert not is_stdpsf('read', filepath, None) assert not is_webbpsf('read', filepath, None) def test_corrupt_fits_filepath(self, tmp_path): """ Test that a corrupt file with a FITS extension is not identified when given only a file path. """ filename = str(tmp_path / 'bad.fits') with open(filename, 'w') as f: f.write('This is not a FITS file.') assert not is_stdpsf('read', filename, None) assert not is_webbpsf('read', filename, None) def test_has_fits_header_keys(self): filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') assert _has_fits_header_keys(filename, ('NAXIS3',)) assert not _has_fits_header_keys(filename, ('NAXIS3', 'NOSUCHKEY')) def test_fileobj_identify(self): """ Test that the format identifiers use the open file object when no file path is available. """ filename = get_data_filename('STDPSF_NRCA1_F150W_mock.fits') with open(filename, 'rb') as fileobj: assert is_stdpsf('read', None, fileobj) assert not is_webbpsf('read', None, fileobj) filename = get_data_filename(WEBBPSF_FILENAMES[0]) with open(filename, 'rb') as fileobj: assert is_webbpsf('read', None, fileobj) assert not is_stdpsf('read', None, fileobj) astropy-photutils-24be529/photutils/psf/tests/test_model_plotting.py000066400000000000000000000153621526452531300262350ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model_plotting module. """ from itertools import product import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.nddata import NDData from numpy.testing import assert_allclose from photutils.psf import GriddedPSFModel, STDPSFGrid from photutils.psf.tests.test_model_io import (STDPSF_FILENAMES, WEBBPSF_FILENAMES, get_data_filename) from photutils.utils._optional_deps import HAS_MATPLOTLIB pytestmark = pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.fixture(autouse=True) def close_figures(): """ Close all matplotlib figures after each test so that the open figure limit is not exceeded. """ yield import matplotlib.pyplot as plt plt.close('all') @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 = {'grid_xypos': list(product(xgrid, ygrid)), 'oversampling': 4} return GriddedPSFModel(NDData(psfs, meta=meta)) class TestPlotGrid: """ Tests for the GriddedPSFModel and STDPSFGrid plot_grid methods. """ def test_plot_grid(self, psfmodel): psfmodel.plot_grid() psfmodel.plot_grid(peak_norm=True, cmap='Blues', vmax_scale=0.9) psfmodel.plot_grid(deltas=True) psfmodel.plot_grid(deltas=True, peak_norm=True) def test_plot_grid_blank_epsfs(self, psfmodel): """ Test a grid where one or more ePSFs are blank (all zeros), as is the case for MIRI with its nonsquare FOV. """ model = psfmodel.deepcopy() model._data[0] = 0.0 model.plot_grid(deltas=True) def test_plot_grid_int_dtype(self): """ Test plotting a grid with integer-dtype ePSF data. """ data = np.ones((4, 8, 8), dtype=int) data[:, 4, 4] = 10 meta = {'grid_xypos': [(0, 0), (0, 10), (10, 0), (10, 10)], 'oversampling': 4} model = GriddedPSFModel(NDData(data, meta=meta)) model.plot_grid(deltas=True) model.plot_grid(peak_norm=True) def test_plot_grid_deltas_peak_norm(self, psfmodel): """ Test that deltas with peak_norm normalizes by the mean-ePSF peak instead of the maximum delta value. """ fig = psfmodel.plot_grid(deltas=True, peak_norm=True) image = fig.axes[0].images[0].get_array() data = psfmodel.data.astype(float) mean_epsf = data.mean(axis=0) expected_max = (data - mean_epsf).max() / mean_epsf.max() assert_allclose(image.max(), expected_max) def test_plot_grid_input_axes(self, psfmodel): """ Test that a user-supplied axes is used and that its figure is returned. """ import matplotlib.pyplot as plt fig_in, ax = plt.subplots() fig_out = psfmodel.plot_grid(ax=ax) assert fig_out is fig_in def test_plot_grid_dividers(self, psfmodel): fig = psfmodel.plot_grid(dividers=False) ax = fig.axes[0] assert len(ax.lines) == 0 fig = psfmodel.plot_grid(dividers=True, divider_color='red', divider_ls=':') ax = fig.axes[0] # 3 vertical and 3 horizontal dividers for a 4x4 grid assert len(ax.lines) == 6 def test_plot_grid_figsize(self, psfmodel): fig = psfmodel.plot_grid(figsize=(6, 5)) assert tuple(fig.get_size_inches()) == (6.0, 5.0) @pytest.mark.parametrize('filename', WEBBPSF_FILENAMES) def test_plot_grid_webbpsf(self, filename): psfmodel = GriddedPSFModel.read(get_data_filename(filename), format='webbpsf') psfmodel.plot_grid() @pytest.mark.parametrize('filename', STDPSF_FILENAMES) def test_plot_grid_stdpsfgrid(self, filename): psfgrid = STDPSFGrid(get_data_filename(filename)) psfgrid.plot_grid() def test_plot_grid_tuple_metadata(self, psfmodel): """ Test that tuple-valued metadata is unwrapped for the plot title. WebbPSF stores each header value as a ``(value, comment)`` tuple. """ model = psfmodel.deepcopy() model.meta['instrument'] = ('NIRCam', 'instrument name') model.meta['detector'] = ['NRCA1', 'detector name'] model.meta['filter'] = np.array(['F200W', 'filter name']) fig = model.plot_grid() assert fig.axes[0].get_title() == 'NIRCam NRCA1 F200W ePSFs' def test_plot_grid_instrume_metadata(self, psfmodel): """ Test the WebbPSF 'instrume' metadata key fallback. """ model = psfmodel.deepcopy() model.meta['instrume'] = 'NIRCam' fig = model.plot_grid() assert fig.axes[0].get_title().startswith('NIRCam') def test_plot_grid_no_metadata(self, psfmodel): """ Test that no leading space is added when there is no instrument, detector, or filter metadata. """ fig = psfmodel.plot_grid() assert fig.axes[0].get_title() == 'ePSFs' def test_plot_grid_nrcsw(self): """ Test the NIRCam NRCSW special case, which adds detector labels and extra divider lines. """ psfgrid = STDPSFGrid(get_data_filename('STDPSF_NRCSW_F150W_mock.fits')) assert psfgrid.meta['detector'] == 'NRCSW' fig = psfgrid.plot_grid() texts = [text.get_text() for text in fig.axes[0].texts] assert set(texts) == {'A1', 'A2', 'A3', 'A4', 'B1', 'B2', 'B3', 'B4'} assert tuple(fig.get_size_inches()) == (20.0, 8.0) @pytest.mark.parametrize('deltas', [False, True]) def test_plot_grid_colorbar_label(self, psfmodel, deltas): for peak_norm in (False, True): fig = psfmodel.plot_grid(deltas=deltas, peak_norm=peak_norm) # The colorbar is the second axes assert fig.axes[1].get_ylabel() != '' def test_plot_grid_deltas_all_blank(): """ Regression test that deltas=True raises a clear error when every ePSF in the grid is blank instead of rendering a NaN image. """ data = np.zeros((4, 5, 5)) meta = {'grid_xypos': [(0, 0), (0, 10), (10, 0), (10, 10)], 'oversampling': 1} model = GriddedPSFModel(NDData(data, meta=meta)) match = 'all ePSFs in the grid are blank' with pytest.raises(ValueError, match=match): model.plot_grid(deltas=True) astropy-photutils-24be529/photutils/psf/tests/test_photometry.py000066400000000000000000003514621526452531300254330ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photometry module. """ import gc import sys import warnings from itertools import pairwise from types import MappingProxyType 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 Const2D, Gaussian1D, 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, ImagePSF, PSFPhotometry, SourceGrouper, make_psf_model, make_psf_model_image) from photutils.psf.flags import decode_psf_flags from photutils.utils.exceptions import (NoDetectionsWarning, PhotutilsDeprecationWarning) @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 nonfinite value match = 'Input data contains unmasked nonfinite 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 nonfinite value match = 'Input data contains unmasked nonfinite 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_validate_type_tuple(): """ Test the type validation error message for a tuple of types. """ match = 'value must be a int or float instance' with pytest.raises(TypeError, match=match): PSFPhotometry._validate_type('a', 'value', (int, float)) 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(PhotutilsDeprecationWarning, match=match): assert isinstance(psfphot.fit_params, Table) def test_psf_photometry_progress_bar(test_data): """ Test that photometry runs with a progress bar enabled. """ 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, progress_bar=True) phot = psfphot(data, error=error) assert isinstance(phot, QTable) assert len(phot) == len(sources) @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 nonpositive or nonfinite 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 nonfinite local background values correctly. When include_local_bkg=True and the local_bkg is nonfinite (NaN or inf), the nonfinite 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 nonfinite 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 nonfinite 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 nonfinite local_bkg, the model with and without # local_bkg should be identical (since nonfinite 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 nonfinite 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 nonfinite 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 nonfinite # 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 a compound model whose x, y, and flux parameters are defined by the ``x_name``, ``y_name``, and ``flux_name`` attributes. """ data, error, sources = test_data x_stddev = y_stddev = 1.2 amplitude = 1.0 / (2.0 * np.pi * x_stddev * y_stddev) psf_model = Gaussian2D(amplitude=amplitude, x_mean=0, y_mean=0, x_stddev=x_stddev, y_stddev=y_stddev) * Const2D(1.0) psf_model.x_name = 'x_mean_0' psf_model.y_name = 'y_mean_0' psf_model.flux_name = 'amplitude_1' psf_model.amplitude_0.fixed = True psf_model.theta_0.fixed = True if not fit_stddev: psf_model.x_stddev_0.fixed = True psf_model.y_stddev_0.fixed = True 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_0', 'y_stddev_0') 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_0', 'y_stddev_0') 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 nonfinite 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 nonfinite 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) # With mask all True, the finder should return 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 nonfinite 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_grouped_fit_two_image_psfs(): """ Regression test that grouped fits with two different same-class PSF models do not reuse a stale cached flat-model class. """ def make_image_psf(sigma2): yy, xx = np.mgrid[:21, :21] psf_data = np.exp(-((xx - 10.0)**2 + (yy - 10.0)**2) / (2 * sigma2)) return ImagePSF(psf_data / psf_data.sum()) def make_scene(psf): yy, xx = np.mgrid[:60, :60] data = np.zeros((60, 60)) for (xpos, ypos), flux in zip(((20.0, 20.0), (24.0, 22.0)), (500.0, 300.0), strict=True): data += psf.evaluate(xx, yy, flux, xpos, ypos) return data init = Table({'x': [20.0, 24.0], 'y': [20.0, 22.0], 'flux': [450.0, 280.0], 'group_id': [1, 1]}) # Disable garbage collection so that a stale cached flat-model # class cannot be silently evicted between the fits gc.disable() try: for sigma2 in (1.0, 16.0): psf = make_image_psf(sigma2) phot = PSFPhotometry(psf, (11, 11)) result = phot(make_scene(psf), init_params=init.copy()) assert_allclose(result['flux_fit'], [500.0, 300.0], rtol=1e-3) finally: gc.enable() def test_image_psf_spline_built_once(spline_builds): """ Test that PSF photometry builds the spline of an ImagePSF model once, instead of once for every source. """ yy, xx = np.mgrid[:21, :21] psf_data = np.exp(-((xx - 10.0)**2 + (yy - 10.0)**2) / 8.0) psf_data /= psf_data.sum() # Four isolated sources and one group of two sources xpos = np.array([20.0, 60.0, 20.0, 60.0, 40.0, 44.0]) ypos = np.array([20.0, 20.0, 60.0, 60.0, 40.0, 42.0]) fluxes = np.array([500.0, 300.0, 400.0, 600.0, 450.0, 350.0]) yy, xx = np.mgrid[:81, :81] data = np.zeros((81, 81)) scene_psf = ImagePSF(psf_data) for xval, yval, flux in zip(xpos, ypos, fluxes, strict=True): data += scene_psf.evaluate(xx, yy, flux, xval, yval) init = Table({'x': xpos + 0.2, 'y': ypos - 0.1, 'flux': 0.9 * fluxes, 'group_id': [1, 2, 3, 4, 5, 5]}) psf_model = ImagePSF(psf_data) spline_builds.count = 0 phot = PSFPhotometry(psf_model, (11, 11)) result = phot(data, init_params=init) assert spline_builds.count == 1 assert_allclose(result['flux_fit'], fluxes, rtol=1e-3) def test_compound_image_psf_spline_not_rebuilt(spline_builds): """ Test that PSF photometry with a compound model that contains an ImagePSF does not build the ImagePSF spline for every source. The copies of a compound model are deep copies. """ yy, xx = np.mgrid[:21, :21] psf_data = np.exp(-((xx - 10.0)**2 + (yy - 10.0)**2) / 8.0) psf_data /= psf_data.sum() xpos = np.array([20.0, 60.0, 20.0, 60.0, 40.0]) ypos = np.array([20.0, 20.0, 60.0, 60.0, 40.0]) fluxes = np.array([500.0, 300.0, 400.0, 600.0, 450.0]) yy, xx = np.mgrid[:81, :81] data = np.zeros((81, 81)) scene_psf = ImagePSF(psf_data) for xval, yval, flux in zip(xpos, ypos, fluxes, strict=True): data += scene_psf.evaluate(xx, yy, flux, xval, yval) init = Table({'x': xpos + 0.2, 'y': ypos - 0.1, 'flux': 0.9 * fluxes}) psf_model = ImagePSF(psf_data) + Const2D(0.0) psf_model.x_name = 'x_0_0' psf_model.y_name = 'y_0_0' psf_model.flux_name = 'flux_0' psf_model.amplitude_1.fixed = True psf_model(20.0, 20.0) spline_builds.count = 0 phot = PSFPhotometry(psf_model, (11, 11)) result = phot(data, init_params=init) assert spline_builds.count == 0 assert_allclose(result['flux_fit'], fluxes, rtol=1e-3) def test_parameter_near_bound_flag_grouped(): """ Regression test that the PARAMETER_NEAR_BOUND flag (bit 32) is set for grouped sources, matching single-source behavior. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image( (50, 50), model, 2, model_shape=(9, 9), flux=(500, 500), min_separation=15, seed=0) # Offset the initial positions so the fit runs into the bound init = Table({'x': params['x_0'] + 2.0, 'y': params['y_0'], 'flux': [500.0, 500.0], 'group_id': [1, 1]}) psfphot = PSFPhotometry(model, (5, 5), xy_bounds=0.5) phot = psfphot(data, init_params=init) assert np.all(phot['flags'] & 32) @pytest.mark.parametrize('flux_init', [[500.0, 700.0, 600.0], [1000.0, 1000.0, 1000.0]]) def test_grouped_fit_fixed_positions(flux_init): """ Regression test that fixed parameters keep their values for grouped sources, and that the fluxes are fit when the initial fluxes do or do not match the true values. """ model = CircularGaussianPRF(fwhm=3) model.x_0.fixed = True model.y_0.fixed = True sources = Table({'x_0': [20.0, 26.0, 60.0], 'y_0': [20.0, 22.0, 60.0], 'flux': [500.0, 700.0, 600.0]}) data = make_model_image((80, 80), model, sources, model_shape=(21, 21)) init = Table({'x': sources['x_0'], 'y': sources['y_0'], 'flux': flux_init}) psfphot = PSFPhotometry(model, (7, 7), grouper=SourceGrouper(10)) phot = psfphot(data, init_params=init) assert_equal(phot['group_size'], [2, 2, 1]) assert_allclose(phot['x_fit'], sources['x_0']) assert_allclose(phot['y_fit'], sources['y_0']) assert_allclose(phot['flux_init'], flux_init) assert_allclose(phot['flux_fit'], sources['flux'], rtol=1e-4) assert_equal(phot['flags'], 0) @pytest.mark.parametrize('sign', [1, -1]) def test_exact_fit_metrics_not_negative_zero(sign): """ Test that an exact fit gives ``qfit`` and ``cfit`` values of zero without a negative sign, for both positive and negative fit fluxes. """ model = CircularGaussianPRF(fwhm=3) model.x_0.fixed = True model.y_0.fixed = True sources = Table({'x_0': [20.0, 60.0], 'y_0': [20.0, 60.0], 'flux': [sign * 300.0, sign * 1600.0]}) data = make_model_image((80, 80), model, sources, model_shape=(21, 21)) init = Table({'x': sources['x_0'], 'y': sources['y_0'], 'flux': sources['flux']}) phot = PSFPhotometry(model, (7, 7))(data, init_params=init) for colname in ('qfit', 'cfit'): assert_equal(phot[colname], 0.0) assert not np.any(np.signbit(phot[colname])) def test_int_data_with_local_bkg(): """ Regression test that integer-dtype data works with a float local_bkg column. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image( (50, 50), model, 3, model_shape=(9, 9), flux=(500, 700), min_separation=10, seed=0) data = (data * 100).astype(int) init = Table({'x': params['x_0'], 'y': params['y_0'], 'local_bkg': [10.5, 10.5, 10.5]}) psfphot = PSFPhotometry(model, (5, 5), aperture_radius=4) phot = psfphot(data, init_params=init) assert len(phot) == 3 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) n_sources = len(phot1) assert isinstance(phot1, QTable) assert_equal(phot1['group_id'], np.ones(n_sources, dtype=int)) assert_equal(phot1['group_size'], np.ones(n_sources, dtype=int) * n_sources) # 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(phot2['group_id'], np.ones(n_sources, dtype=int)) assert_equal(phot2['group_size'], np.ones(n_sources, dtype=int) * n_sources) def test_grouper_not_mutated_by_group_id(): """ Regression test that a call with init_params['group_id'] does not disable the grouper for subsequent calls. """ model = CircularGaussianPRF(fwhm=2.7) data, true_params = make_psf_model_image( (80, 80), model, 10, model_shape=(9, 9), flux=(500, 700), min_separation=3, seed=0) init = Table({'x': true_params['x_0'], 'y': true_params['y_0']}) grouper = SourceGrouper(min_separation=10) psfphot = PSFPhotometry(model, (5, 5), grouper=grouper, aperture_radius=4) phot1 = psfphot(data, init_params=init) init2 = init.copy() init2['group_id'] = np.ones(len(init2), dtype=int) psfphot(data, init_params=init2) assert psfphot.grouper is grouper phot3 = psfphot(data, init_params=init) assert_equal(phot1['group_id'], phot3['group_id']) 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_init_flux(): """ Test that the aperture-based initial flux has the local background subtracted over the whole aperture area. """ model = CircularGaussianPRF(fwhm=3) sources = Table({'x_0': [30.0, 2.0], 'y_0': [30.0, 30.0], 'flux': [1000.0, 1000.0]}) data = make_model_image((61, 61), model, sources, model_shape=(31, 31)) data += 5.0 init = Table({'x': sources['x_0'], 'y': sources['y_0'], 'local_bkg': [5.0, 5.0]}) psfphot = PSFPhotometry(model, (7, 7), aperture_radius=5) phot = psfphot(data, init_params=init) # The aperture of the second source is cut off by the image edge assert_allclose(phot['flux_init'][0], 1000.0, rtol=1e-3) assert 950 < phot['flux_init'][1] < 1000 assert_allclose(phot['flux_fit'], 1000.0) def test_local_bkg_nonfinite(test_data): """ Test that nonfinite 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 nonfinite 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 nonfinite 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 nonfinite local_bkg psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error, init_params=sources) # Check that nonfinite 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 nonfinite 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 nonfinite 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 nonfinite 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) @pytest.mark.parametrize('grouper', [None, SourceGrouper(20)]) def test_all_params_fixed(test_data, grouper): """ Test that a PSF model with no free parameters raises a clear error. """ 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, grouper=grouper, aperture_radius=4) match = 'psf_model has no free parameters to fit' 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 np.all(phot['flags'] & 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 = "'fitter_maxiters' will be ignored because the fitter's" 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 # Start closer to the source in y because the fit converges slowly # from y=51 while x_0 is held at its bound. xy_bounds = (1, None) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds) init_params['y'] = [50] 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)) match = 'xy_bounds must be numeric' with pytest.raises(ValueError, match=match): PSFPhotometry(psf_model, fit_shape, xy_bounds='a') 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) with pytest.warns(PhotutilsDeprecationWarning): psf1 = make_psf_model(psf0, x_name='x_mean', y_name='y_mean', normalize=normalize) with pytest.warns(PhotutilsDeprecationWarning): psf2 = make_psf_model(psf0, normalize=normalize) with pytest.warns(PhotutilsDeprecationWarning): psf3 = make_psf_model(psf0, x_name='x_mean', normalize=normalize) with pytest.warns(PhotutilsDeprecationWarning): 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 # Sources fit outside the image are also outside the fit region, # which is trimmed to the image assert_equal(phot['flags'][[2, 3, 6, 7]], [4099, 4099, 4099, 4099]) assert_equal(phot['flags'][[0, 1, 4, 5]], [1, 1, 1, 1]) def test_flag64_edge_no_overlap(): """ Test flag=64 for a source whose fit region ends exactly at the lower edge of the image. Regression test for the array-valued ``fit_shape`` being compared to a tuple in ``overlap_slices``. """ psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros((21, 21)) init_params = QTable() init_params['x_0'] = [-3.0, 10.0] init_params['y_0'] = [10.0, -3.0] init_params['flux'] = [500.0, 500.0] psfphot = PSFPhotometry(psf_model, (5, 5)) phot = psfphot(data, init_params=init_params) assert len(phot) == 2 assert np.all((phot['flags'] & 64) == 64) assert np.all(phot['n_pixels_fit'] == 0) 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) and 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 = "'fitter_maxiters' will be ignored because the fitter's" 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 @pytest.mark.filterwarnings('ignore:One or more fit.* may not have ' 'converged:astropy.utils.exceptions.' 'AstropyUserWarning') @pytest.mark.parametrize('fitter', [TRFLSQFitter, LevMarLSQFitter]) def test_flag16_degenerate_covariance(fitter): """ Test that nonfinite parameter errors from a degenerate fit are always flagged as missing covariance (flag=16). Two grouped sources at the same position have degenerate fluxes, so the fit covariance matrix is singular. The fitters either return a covariance matrix with finite (but large) errors or none at all. Whether the fit also emits a convergence warning depends on the platform, so that warning is ignored. """ psf_model = CircularGaussianPRF(fwhm=3.0) sources = QTable({'x_0': [12.0], 'y_0': [12.0], 'flux': [1000.0]}) data = make_model_image((25, 25), psf_model, sources) data += make_noise_image(data.shape, mean=0.0, stddev=1.0, seed=0) init_params = QTable({'x_0': [12.0, 12.0], 'y_0': [12.0, 12.0], 'flux': [500.0, 500.0], 'group_id': [1, 1]}) psfphot = PSFPhotometry(psf_model, (7, 7), fitter=fitter()) phot = psfphot(data, init_params=init_params) errs = np.array([phot[col] for col in ('x_err', 'y_err', 'flux_err')]) non_finite = ~np.all(np.isfinite(errs), axis=0) assert_equal(non_finite, (phot['flags'] & 16) > 0) 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 nonfinite fitted position. When a source has nonfinite initial position or when the fit fails to converge properly, the fitted x or y position can be nonfinite. """ shape = (25, 25) psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros(shape) # Create init_params with nonfinite 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 nonfinite x_fit (flag 512 set) assert not np.isfinite(phot['x_fit'][1]) assert (phot['flags'][1] & 512) == 512 # Third source should have nonfinite 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 nonfinite flux in init_params. When a source has nonfinite 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 nonfinite 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 nonfinite flux_fit (flag 1024 set) assert not np.isfinite(phot['flux_fit'][1]) assert (phot['flags'][1] & 1024) == 1024 # Third source should have nonfinite flux_fit (flag 1024 set) assert not np.isfinite(phot['flux_fit'][2]) assert (phot['flags'][2] & 1024) == 1024 @pytest.mark.parametrize(('x_init', 'y_init', 'dx', 'dy', 'expected'), [ # The 5x5 fit region spans pixel edges 9.5 to 14.5 (12.0, 12.0, 2.4, 0.0, False), (12.0, 12.0, 2.5, -2.5, False), (12.0, 12.0, 2.6, 0.0, True), (12.0, 12.0, -2.6, 0.0, True), (12.0, 12.0, 0.0, 2.6, True), (12.0, 12.0, 0.0, -2.6, True), (12.3, 11.6, 2.1, 0.0, False), # region spans x edges 9.5 to 14.5 (12.6, 11.6, -2.2, 0.0, True), # region spans x edges 10.5 to 15.5 # The fit region is trimmed to the image, spanning x edges -0.5 # to 3.5 (1.0, 12.0, 2.6, 0.0, True), (1.0, 12.0, -1.4, 0.0, False), ]) def test_flag4096_position_outside_fit_region(x_init, y_init, dx, dy, expected): """ Test flag=4096 for a fitted position outside the fitted data region. A mock fitter shifts the fitted position by a known offset from the initial position. """ shape = (25, 25) psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros(shape) init_params = QTable() init_params['x_0'] = [x_init] init_params['y_0'] = [y_init] init_params['flux'] = [500.0] def mock_fitter(model, *args, **kwargs): # noqa: ARG001 model = model.copy() model.x_0 += dx model.y_0 += dy return model mock_fitter.fit_info = {'status': 1} match = "'fitter_maxiters' will be ignored because the fitter's" with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, (5, 5), fitter=mock_fitter) phot = psfphot(data, init_params=init_params) assert_allclose(phot['x_fit'][0], x_init + dx) assert_allclose(phot['y_fit'][0], y_init + dy) assert bool(phot['flags'][0] & 4096) is expected # The trimmed region case lies inside the image if x_init == 1.0: assert (phot['flags'][0] & 2) == 0 def test_flag4096_position_outside_fit_region_fit(): """ Test flag=4096 when a fit converges on a bright neighbor outside the fitted data region. """ shape = (25, 41) psf_model = CircularGaussianPRF(fwhm=3.0) sources = QTable() sources['x_0'] = [20.0] sources['y_0'] = [12.0] sources['flux'] = [1.0e5] data = make_model_image(shape, psf_model, sources) # The fit region spans x edges 13.5 to 18.5 init_params = QTable() init_params['x_0'] = [16.0] init_params['y_0'] = [12.0] init_params['flux'] = [1.0e4] psfphot = PSFPhotometry(psf_model, (5, 5)) phot = psfphot(data, init_params=init_params) assert_allclose(phot['x_fit'][0], 20.0) assert phot['flags'][0] == 4096 def test_flag4096_non_finite_position(): """ Test that flag=4096 is not set for a nonfinite fitted position. """ psf_model = CircularGaussianPRF(fwhm=3.0) data = np.zeros((25, 25)) init_params = QTable() init_params['x_0'] = [12.0, 40.0] init_params['y_0'] = [12.0, 12.0] init_params['flux'] = [500.0, 500.0] psfphot = PSFPhotometry(psf_model, (5, 5)) phot = psfphot(data, init_params=init_params) assert np.isnan(phot['x_fit'][1]) assert (phot['flags'][1] & 64) == 64 assert not np.any(phot['flags'] & 4096) 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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_results_to_params_exclude_n_pixels(): """ Regression test that the n_pixels_fit column does not leak into the init/model parameter tables. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image( (60, 60), model, 5, model_shape=(9, 9), flux=(500, 700), min_separation=10, seed=0) init = Table({'x': params['x_0'], 'y': params['y_0']}) psfphot = PSFPhotometry(model, (5, 5), aperture_radius=4) psfphot(data, init_params=init) init_tbl = psfphot.results_to_init_params() assert 'n_pixels_init' not in init_tbl.colnames model_tbl = psfphot.results_to_model_params() assert 'n_pixels' not in model_tbl.colnames assert set(model_tbl.colnames) == {'id', 'x_0', 'y_0', 'flux'} def test_duplicate_init_params_ids(): """ Regression test that duplicate init_params ids are rejected instead of producing a cartesian product of mispaired rows. """ model = CircularGaussianPRF(fwhm=2.7) data = np.zeros((30, 30)) init = Table({'id': [1, 1], 'x': [12.0, 13.0], 'y': [12.0, 13.0], 'flux': [1.0, 1.0]}) psfphot = PSFPhotometry(model, (5, 5)) match = 'id column must contain unique values' with pytest.raises(ValueError, match=match): psfphot(data, init_params=init) def test_finder_results_reset(test_data): """ Regression test that finder_results from a finder-based call does not persist into a subsequent call that uses init_params. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, (5, 5), finder=finder, aperture_radius=4) phot = psfphot(data, error=error) assert psfphot.finder_results is not None init = Table({'x': phot['x_fit'], 'y': phot['y_fit']}) psfphot(data, error=error, init_params=init) assert psfphot.finder_results is None def test_empty_init_params(): """ Test that a zero-row init_params table raises an error. """ model = CircularGaussianPRF(fwhm=2.7) data = np.ones((11, 11)) init = Table({'x': [1.0], 'y': [1.0]})[:0] psfphot = PSFPhotometry(model, (3, 3), aperture_radius=3) match = 'init_params must contain at least one row' with pytest.raises(ValueError, match=match): psfphot(data, init_params=init) def test_reduced_chi2_zero_dof(): """ Regression test that reduced_chi2 is NaN (with no warning) when the number of fit pixels equals the number of fit parameters. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image( (50, 50), model, 1, model_shape=(9, 9), flux=(500, 700), min_separation=10, seed=0) error = np.ones(data.shape) xcen = int(params['x_0'][0]) ycen = int(params['y_0'][0]) # Unmask exactly 3 pixels, matching the number of fit parameters mask = np.ones(data.shape, dtype=bool) mask[ycen, xcen] = False mask[ycen, xcen + 1] = False mask[ycen + 1, xcen] = False init = Table({'x': params['x_0'], 'y': params['y_0'], 'flux': [600.0]}) psfphot = PSFPhotometry(model, (3, 3)) phot = psfphot(data, error=error, mask=mask, init_params=init) assert np.isnan(phot['reduced_chi2'][0]) def test_group_warning_threshold_validation(): """ Test that invalid group_warning_threshold values raise an error. """ model = CircularGaussianPRF(fwhm=2.7) match = 'group_warning_threshold must be a positive integer' for value in (None, -1, 2.5): with pytest.raises(ValueError, match=match): PSFPhotometry(model, (5, 5), group_warning_threshold=value) def test_row_index_not_leaked_on_failure(): """ Regression test that a failed call does not leak the private _row_index column into the stored init_params table. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image( (50, 50), model, 3, model_shape=(9, 9), flux=(500, 700), min_separation=10, seed=0) error = np.full(data.shape, -1.0) init = Table({'x': params['x_0'], 'y': params['y_0'], 'flux': [500.0, 500.0, 500.0]}) psfphot = PSFPhotometry(model, (5, 5)) match = 'Error array contains nonpositive' with pytest.raises(ValueError, match=match): psfphot(data, error=error, init_params=init) assert '_row_index' not in psfphot.init_params.colnames def test_group_id_from_float_id_message(): """ Test that the group_id dtype error names the id column when group_id was derived from it. """ model = CircularGaussianPRF(fwhm=2.7) data = np.ones((11, 11)) init = Table({'id': [1.0, 2.0], 'x': [3.0, 7.0], 'y': [3.0, 7.0], 'flux': [1.0, 1.0]}) psfphot = PSFPhotometry(model, (3, 3)) match = (r'group_id \(or the id column it was derived from\) ' 'must be an integer array') with pytest.raises(TypeError, match=match): psfphot(data, init_params=init) def test_aperture_radius_0d_array(): """ Test that a 0-d array aperture_radius is accepted. """ model = CircularGaussianPRF(fwhm=2.7) psfphot = PSFPhotometry(model, (5, 5), aperture_radius=np.array(3.0)) assert psfphot.aperture_radius == 3.0 def test_deprecated_shims(test_data): """ Test the deprecated keyword and positional-argument shims. """ data, error, _ = test_data model = CircularGaussianPRF(flux=1, fwhm=2.7) finder = DAOStarFinder(6.0, 2.0) match = "'localbkg_estimator' was deprecated in version 3.0" with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot = PSFPhotometry(model, (5, 5), finder=finder, aperture_radius=4, localbkg_estimator=None) psfphot(data, error=error) match = "'include_localbkg' was deprecated in version 3.0" with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot.make_model_image((25, 25), include_localbkg=False) with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot.make_residual_image(data, include_localbkg=False) match = ("Passing 'return_bit_values' positionally to " "'decode_flags' is deprecated") with pytest.warns(PhotutilsDeprecationWarning, match=match): psfphot.decode_flags(True) # noqa: FBT003 def test_psf_photometry_table_serialization(test_data, tmp_path): """ 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 filename = tmp_path / 'psf_results.ecsv' # Write table to ECSV format results.write(filename, format='ascii.ecsv', overwrite=True) # Read it back to verify it's readable read_table = Table.read(filename, 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 nonfinite 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 nonfinite 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 nonfinite 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. 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['n_pixels'] == 0 assert np.isnan(result['cen_index']) def test_flags_with_invalid_and_nonfinite_sources(): """ Test flag computation with invalid and nonfinite sources. This test creates scenarios with invalid sources and sources that end up with nonfinite 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): # Array values (e.g., param_cov) can differ by # float rounding when the within-group source # order differs, so compare with a tolerance if not np.allclose(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() n_sources = len(sources) rng = np.random.default_rng(seed=0) init_params['id'] = np.arange(1, n_sources + 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=n_sources) 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: # Nonconsecutive random ids, monotonically increasing so final # results order should be same steps = rng.integers(1, 51, size=n_sources - 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(n_sources)] 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 assert _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'], rtol=1e-6) assert_allclose(phot_small_error['qfit'], phot_no_error['qfit']) assert_allclose(phot_small_error['cfit'], phot_no_error['cfit'], rtol=1e-6) assert_allclose(phot_large_error['qfit'], phot_no_error['qfit']) assert_allclose(phot_large_error['cfit'], phot_no_error['cfit'], rtol=1e-6) 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 non_positive_flux flag) m2 = CircularGaussianPRF(flux=-50, x_0=5, y_0=5, fwhm=2) # Source 3: outside the image (will have position_outside_image 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 dictionary keyed by source id assert isinstance(decoded_flags, dict) assert len(decoded_flags) == len(results) assert list(decoded_flags) == list(results['id']) assert all(type(key) is int for key in decoded_flags) # Each value should be a list of strings for decoded in decoded_flags.values(): 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[1], list) # Check that the second source has the non_positive_flux flag assert 'non_positive_flux' in decoded_flags[2] # 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[3]) > 0 assert 'no_overlap' in decoded_flags[3] # Verify that decode_flags gives the same result as calling # decode_psf_flags directly direct_decoded = decode_psf_flags(results['flags']) assert list(decoded_flags.values()) == direct_decoded # Custom, nonsequential ids key the decoded dictionary init_params['id'] = [7, 3, 11] results2 = psfphot(data, init_params=init_params) decoded2 = psfphot.decode_flags() assert list(decoded2) == list(results2['id']) assert 'non_positive_flux' in decoded2[3] def test_int_mask_matches_bool_mask(): """ Regression test that an integer 0/1 mask array behaves identically to the equivalent boolean mask. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image((35, 35), model, 1, model_shape=(9, 9), flux=(500, 500), min_separation=10, seed=0) init = Table({'x': params['x_0'], 'y': params['y_0']}) mask = np.zeros(data.shape, dtype=bool) mask[int(params['y_0'][0]), int(params['x_0'][0])] = True psfphot = PSFPhotometry(model, (5, 5), aperture_radius=4) phot_bool = psfphot(data, init_params=init, mask=mask) phot_int = psfphot(data, init_params=init, mask=mask.astype(int)) assert_equal(phot_int['n_pixels_fit'], phot_bool['n_pixels_fit']) assert_equal(phot_int['flags'], phot_bool['flags']) assert_allclose(phot_int['flux_fit'], phot_bool['flux_fit']) @pytest.mark.parametrize('maxiters', [-5, 0, 3.7, 'abc', True]) def test_invalid_fitter_maxiters(maxiters): model = CircularGaussianPRF(fwhm=2.7) match = 'fitter_maxiters must be a strictly-positive integer' with pytest.raises(ValueError, match=match): PSFPhotometry(model, (5, 5), fitter_maxiters=maxiters) def _init_params_with_groups_and_invalid_sources(sources): """ Initial parameters from the true sources, with two sources fitted as a group and three invalid sources (a NaN position, a position far outside the image, and a nonfinite flux). """ init_params = QTable() init_params['x_init'] = [*sources['x_0'], np.nan, 500.0, 40.0] init_params['y_init'] = [*sources['y_0'], 40.0, 500.0, 40.0] init_params['flux_init'] = [*sources['flux'], 500.0, 500.0, np.nan] group_id = np.arange(1, len(init_params) + 1) group_id[1] = group_id[0] init_params['group_id'] = group_id return init_params def _assert_fit_info_equal(info_a, info_b): assert len(info_a) == len(info_b) for fa, fb in zip(info_a, info_b, strict=True): assert fa.keys() == fb.keys() for key in fa: if key == 'param_cov': assert_equal(fa[key], fb[key]) else: assert fa[key] == fb[key] def _image_psf_model(): yy, xx = np.mgrid[-12:13, -12:13] psf_data = CircularGaussianPRF(flux=1, fwhm=2.7)(xx, yy) return ImagePSF(psf_data) @pytest.mark.usefixtures('gil_disabled') @pytest.mark.parametrize('make_model', [ lambda: CircularGaussianPRF(flux=1, fwhm=2.7), _image_psf_model]) def test_n_threads_identical(test_data, make_model): """ Test that fitting in multiple threads gives identical results to the single-threaded fit, including grouped and invalid sources. """ data, error, sources = test_data init_params = _init_params_with_groups_and_invalid_sources(sources) phots = [] infos = [] for n_threads in (1, 3): psfphot = PSFPhotometry(make_model(), (5, 5), aperture_radius=4, n_threads=n_threads) phots.append(psfphot(data, error=error, init_params=init_params)) infos.append(psfphot.fit_info) assert psfphot.n_threads == n_threads assert f'n_threads={n_threads}' in repr(psfphot) phot1, phot2 = phots assert phot1.colnames == phot2.colnames for name in phot1.colnames: assert_equal(np.asarray(phot1[name]), np.asarray(phot2[name]), err_msg=name) assert phot2['group_size'][0] == 2 assert np.all(phot2['flags'][-3:] > 0) assert np.all(np.isfinite(phot2['x_fit'][:-3])) _assert_fit_info_equal(infos[0], infos[1]) @pytest.mark.usefixtures('gil_disabled') def test_n_threads_inputs_unchanged(test_data): """ Test that the threads fit copies of the PSF model and the fitter, leaving the input objects untouched. """ data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fitter = TRFLSQFitter() init_params = _init_params_with_groups_and_invalid_sources(sources) fit_info = fitter.fit_info psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=4, fitter=fitter, n_threads=2) psfphot(data, error=error, init_params=init_params) assert psfphot.fitter is fitter assert fitter.fit_info is fit_info assert psfphot.psf_model.flux == 1 @pytest.mark.usefixtures('gil_disabled') def test_n_threads_progress_bar(test_data): """ Test the threaded path with a progress bar and more threads than groups. """ data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) init_params = QTable() init_params['x_init'] = sources['x_0'][:2] init_params['y_init'] = sources['y_0'][:2] psfphot1 = PSFPhotometry(psf_model, (5, 5), aperture_radius=4) psfphot2 = PSFPhotometry(psf_model, (5, 5), aperture_radius=4, n_threads=8, progress_bar=True) phot1 = psfphot1(data, error=error, init_params=init_params) phot2 = psfphot2(data, error=error, init_params=init_params) for name in ('x_fit', 'y_fit', 'flux_fit', 'flags', 'qfit'): assert_equal(np.asarray(phot1[name]), np.asarray(phot2[name])) @pytest.mark.usefixtures('gil_disabled') def test_n_threads_fit_error(test_data): """ Test that an error raised while fitting in a thread is raised by the call. """ data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) init_params = _init_params_with_groups_and_invalid_sources(sources) error = error.copy() error[int(sources['y_0'][3]), int(sources['x_0'][3])] = 0.0 psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=4, n_threads=2) match = 'Error array contains nonpositive or nonfinite values' with pytest.raises(ValueError, match=match): psfphot(data, error=error, init_params=init_params) def test_n_threads_gil_fallback(test_data, monkeypatch): """ Test that n_threads > 1 warns and fits in a single thread when the GIL is enabled. """ data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) init_params = _init_params_with_groups_and_invalid_sources(sources) psfphot1 = PSFPhotometry(psf_model, (5, 5), aperture_radius=4) phot1 = psfphot1(data, error=error, init_params=init_params) fitter = TRFLSQFitter() psfphot2 = PSFPhotometry(psf_model, (5, 5), aperture_radius=4, fitter=fitter, n_threads=4) monkeypatch.setattr('photutils.psf.photometry._gil_enabled', lambda: True) match = 'the sources are fitted in a single thread' with pytest.warns(AstropyUserWarning, match=match): phot2 = psfphot2(data, error=error, init_params=init_params) assert psfphot2.n_threads == 4 for name in phot1.colnames: assert_equal(np.asarray(phot1[name]), np.asarray(phot2[name]), err_msg=name) # The single-threaded fit calls the input fitter assert fitter.fit_info is not None def test_n_threads_gil_fallback_single_group(test_data, monkeypatch): """ Test that the fallback warning is not issued when there is only one group to fit. """ data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) init_params = QTable() init_params['x_init'] = sources['x_0'][:1] init_params['y_init'] = sources['y_0'][:1] monkeypatch.setattr('photutils.psf.photometry._gil_enabled', lambda: True) psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=4, n_threads=4) with warnings.catch_warnings(): warnings.simplefilter('error') phot = psfphot(data, error=error, init_params=init_params) assert np.isfinite(phot['x_fit'][0]) def test_n_threads_build_detection(test_data): """ Test that the fallback warning is issued only on a Python build with the GIL enabled. """ data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) init_params = QTable() init_params['x_init'] = sources['x_0'][:4] init_params['y_init'] = sources['y_0'][:4] psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=4, n_threads=2) gil_enabled = getattr(sys, '_is_gil_enabled', lambda: True)() with warnings.catch_warnings(record=True) as records: warnings.simplefilter('always') phot = psfphot(data, error=error, init_params=init_params) messages = [str(record.message) for record in records] fell_back = any('fitted in a single thread' in msg for msg in messages) assert fell_back == gil_enabled assert np.all(np.isfinite(phot['x_fit'])) def test_fitter_non_dict_fit_info(test_data): """ Test that a fitter whose fit_info is not a dictionary gives NaN residual-based fit metrics. """ class MappingFitInfoFitter(TRFLSQFitter): def __call__(self, model, x, y, z, *, weights=None, maxiter=100, inplace=False): fit_model = super().__call__(model, x, y, z, weights=weights, maxiter=maxiter, inplace=inplace) self.fit_info = MappingProxyType(dict(self.fit_info)) return fit_model data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) init_params = QTable() init_params['x_init'] = sources['x_0'][:3] init_params['y_init'] = sources['y_0'][:3] psfphot = PSFPhotometry(psf_model, (5, 5), aperture_radius=4, fitter=MappingFitInfoFitter()) phot = psfphot(data, error=error, init_params=init_params) assert np.all(np.isfinite(phot['x_fit'])) assert np.all(np.isfinite(phot['flux_err'])) for name in ('qfit', 'cfit', 'reduced_chi2'): assert np.all(np.isnan(phot[name])) @pytest.mark.parametrize('n_threads', [-1, 0, 2.5, 'abc', True]) def test_invalid_n_threads(n_threads): model = CircularGaussianPRF(fwhm=2.7) match = 'n_threads must be a positive integer' with pytest.raises(ValueError, match=match): PSFPhotometry(model, (5, 5), n_threads=n_threads) def test_chunk_groups(): """ Test that the group chunks hold complete groups with roughly equal numbers of sources and cover every source once. """ init_params = QTable() init_params['_row_index'] = np.arange(20) init_params['group_id'] = np.repeat(np.arange(1, 8), [1, 4, 2, 6, 1, 5, 1]) grouped = init_params.group_by('group_id') for n_chunks in (1, 3, 7, 20): chunks = PSFPhotometry._chunk_groups(grouped, n_chunks) assert len(chunks) <= min(n_chunks, 7) rows = np.concatenate([np.asarray(chunk['_row_index']) for chunk in chunks]) assert_equal(np.sort(rows), np.arange(20)) chunk_ids = [set(chunk['group_id']) for chunk in chunks] for ids_a, ids_b in pairwise(chunk_ids): assert not ids_a & ids_b def test_valid_fitter_maxiters(): model = CircularGaussianPRF(fwhm=2.7) psfphot = PSFPhotometry(model, (5, 5), fitter_maxiters=np.int64(50)) assert psfphot.fitter_maxiters == 50 def test_nddata_with_explicit_mask_or_error(): """ Regression test that explicit mask/error keywords are rejected (not silently ignored) when data is an NDData instance. """ model = CircularGaussianPRF(fwhm=2.7) data = np.ones((25, 25)) init = Table({'x': [12.0], 'y': [12.0], 'flux': [1.0]}) psfphot = PSFPhotometry(model, (5, 5)) match = 'must be None when data is an NDData instance' with pytest.raises(ValueError, match=match): psfphot(NDData(data), error=np.ones(data.shape), init_params=init) with pytest.raises(ValueError, match=match): psfphot(NDData(data), mask=np.zeros(data.shape, dtype=bool), init_params=init) def test_aperture_radius_bool(): model = CircularGaussianPRF(fwhm=2.7) match = 'aperture_radius must be a strictly-positive scalar' with pytest.raises(ValueError, match=match): PSFPhotometry(model, (5, 5), aperture_radius=True) def test_finder_empty_table(): """ Regression test that a finder returning a zero-row table is treated like no sources found instead of raising an aperture error. """ def finder(data, *, mask=None): # noqa: ARG001 return Table({'x': np.array([]), 'y': np.array([])}) model = CircularGaussianPRF(fwhm=2.7) psfphot = PSFPhotometry(model, (5, 5), finder=finder, aperture_radius=4) phot = psfphot(np.ones((25, 25))) assert phot is None def test_parameter_near_bound_flag_model_bounds(): """ Regression test that the parameter_near_bound flag (bit 32) is set when the fit is pinned at a bound set on the PSF model itself, without the xy_bounds keyword. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image((35, 35), model, 1, model_shape=(9, 9), flux=(500, 500), min_separation=10, seed=0) xpos = params['x_0'][0] fit_model = CircularGaussianPRF(fwhm=2.7) fit_model.x_0.bounds = (xpos - 0.6, xpos - 0.3) init = Table({'x': [xpos - 0.5], 'y': params['y_0'], 'flux': [500.0]}) psfphot = PSFPhotometry(fit_model, (5, 5)) phot = psfphot(data, init_params=init) assert_allclose(phot['x_fit'][0], xpos - 0.3, atol=0.02) assert phot['flags'][0] & 32 def test_parameter_near_bound_flag_flux_bounds_units(): """ Regression test that the parameter_near_bound flag (bit 32) is evaluated for a flux parameter with bounds when the data has units (the fitted flux is then a Quantity). """ unit = u.Jy model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image((35, 35), model, 1, model_shape=(9, 9), flux=(500, 500), min_separation=10, seed=0) data <<= unit init = Table({'x': params['x_0'], 'y': params['y_0'], 'flux': [650.0 * unit]}) # Bounds that pin the fitted flux at the lower bound fit_model = CircularGaussianPRF(fwhm=2.7) fit_model.flux.bounds = (600.0, 700.0) psfphot = PSFPhotometry(fit_model, (5, 5)) phot = psfphot(data, init_params=init) assert phot['flux_fit'].unit == unit assert_allclose(phot['flux_fit'][0].value, 600.0, atol=0.02) assert phot['flags'][0] & 32 # Bounds that do not constrain the fit fit_model = CircularGaussianPRF(fwhm=2.7) fit_model.flux.bounds = (0.0, 1000.0) psfphot = PSFPhotometry(fit_model, (5, 5)) phot = psfphot(data, init_params=init) assert_allclose(phot['flux_fit'][0].value, 500.0, rtol=0.01) assert not phot['flags'][0] & 32 def test_error_weights_message_has_source_context(): """ Regression test that the invalid-error-value message names the fit region location. """ model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image((35, 35), model, 1, model_shape=(9, 9), flux=(500, 500), min_separation=10, seed=0) init = Table({'x': params['x_0'], 'y': params['y_0'], 'flux': [500.0]}) error = np.ones(data.shape) error[int(params['y_0'][0]), int(params['x_0'][0])] = 0.0 psfphot = PSFPhotometry(model, (5, 5)) match = 'centered near' with pytest.raises(ValueError, match=match): psfphot(data, error=error, init_params=init) @pytest.mark.parametrize('dtype', [np.float32, np.float64]) @pytest.mark.parametrize('grouped', [False, True]) def test_read_only_inputs(dtype, grouped): """ Regression test that read-only (nonwritable) input arrays are accepted, are not modified, and give results identical to writable arrays. """ yy, xx = np.mgrid[-12:13, -12:13] psf_data = CircularGaussianPRF(fwhm=5.4)(xx, yy) psf_model = ImagePSF(psf_data, oversampling=2) params = Table({'x_0': [20.3, 25.1, 50.7], 'y_0': [20.6, 23.2, 40.2], 'flux': [500.0, 300.0, 800.0]}) shape = (61, 71) data = make_model_image(shape, psf_model, params, model_shape=(11, 11)) data += make_noise_image(shape, mean=0, stddev=0.5, seed=0) data = data.astype(dtype) error = np.full(shape, 0.5, dtype=dtype) mask = np.zeros(shape, dtype=bool) mask[40, 52] = True x_init = np.array(params['x_0']) + 0.2 y_init = np.array(params['y_0']) - 0.1 arrays = (psf_data, data, error, mask, x_init, y_init) originals = [arr.copy() for arr in arrays] def compute(): model = ImagePSF(psf_data, oversampling=2) grouper = SourceGrouper(10) if grouped else None psfphot = PSFPhotometry(model, (7, 7), grouper=grouper, aperture_radius=4, local_bkg_estimator=LocalBackground(6, 10)) init_params = Table({'x': x_init, 'y': y_init}) phot = psfphot(data, error=error, mask=mask, init_params=init_params) result = [np.asarray(phot[name]) for name in phot.colnames] result.append(psfphot.make_model_image(shape)) result.append(psfphot.make_residual_image(data)) return result expected = compute() for arr in arrays: arr.setflags(write=False) result = compute() for res, exp in zip(result, expected, strict=True): assert_equal(res, exp) for arr, original in zip(arrays, originals, strict=True): assert_equal(arr, original) astropy-photutils-24be529/photutils/psf/tests/test_positional_kwargs.py000066400000000000000000000026121526452531300267460ustar00rootroot00000000000000# 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 photutils.psf.flags import decode_psf_flags from photutils.psf.groupers import SourceGrouper from photutils.utils.exceptions import PhotutilsDeprecationWarning class TestDecodePSFFlagsPositionalKwargs: """ Test decode_psf_flags warns for positional optional args. """ def test_positional_warns(self): match = 'decode_psf_flags' with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/psf/tests/test_simulation.py000066400000000000000000000072331526452531300253770ustar00rootroot00000000000000# 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 Const2D, Gaussian2D from astropy.table import Table from numpy.testing import assert_equal from photutils.psf import CircularGaussianPRF, make_psf_model_image def make_compound_psf_model(): """ Make a compound PSF model whose x, y, and flux parameters are defined by the ``x_name``, ``y_name``, and ``flux_name`` attributes. """ psf_model = Gaussian2D(amplitude=1.0 / (2.0 * np.pi)) * Const2D(1.0) psf_model.x_name = 'x_mean_0' psf_model.y_name = 'y_mean_0' psf_model.flux_name = 'amplitude_1' return psf_model 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 psf_model = make_compound_psf_model() 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_flux_mapping(): """ Test that the flux kwarg maps to the flux parameter name of a model with a ``flux_name`` attribute. """ shape = (100, 100) n_sources = 5 psf_model = make_compound_psf_model() flux = (100, 200) _, params = make_psf_model_image(shape, psf_model, n_sources, model_shape=(11, 11), flux=flux, seed=0) flux_name = psf_model.flux_name assert flux_name in params.colnames assert np.min(params[flux_name]) >= flux[0] assert np.max(params[flux_name]) <= flux[1] 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-24be529/photutils/psf/tests/test_utils.py000066400000000000000000000423541526452531300243560ustar00rootroot00000000000000# 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, Identity 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.flags import decode_psf_flags 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('nonfinite 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 def test_fit_2dgaussian_user_mask_with_nan(): """ Regression test that unmasked nonfinite values are merged into a user-provided mask. """ rng = np.random.default_rng(0) yy, xx = np.mgrid[0:31, 0:31] data = Gaussian2D(100, 15, 15, 3, 3)(xx, yy) data += rng.normal(0, 0.1, data.shape) data[5, 5] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True # Expect exactly two warnings: fit_shape is None and unmasked # nonfinite values with pytest.warns(AstropyUserWarning) as record: fit = fit_2dgaussian(data, mask=mask, fix_fwhm=False) 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('unmasked nonfinite values' in msg for msg in warning_msgs) assert_allclose(fit.results['x_fit'][0], 15.0, atol=0.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[column].unit == unit @pytest.mark.parametrize('with_units', [False, True]) def test_fit_2dgaussian_no_overlap(test_data, with_units): """ Test that a position whose fit region is off the image is flagged instead of raising an error. """ data, sources = test_data if with_units: data = data * u.nJy xypos = [(sources['x_0'][0], sources['y_0'][0]), (200.0, 50.0)] fit = fit_2dgaussian(data, xypos=xypos, fit_shape=(5, 5), fix_fwhm=False) fit_tbl = fit.results assert_allclose(fit_tbl['fwhm_fit'][0], sources['fwhm'][0]) assert np.isnan(fit_tbl['fwhm_fit'][1]) assert fit_tbl['flags'][0] == 0 assert 'no_overlap' in decode_psf_flags(fit_tbl['flags'][1]) fwhm = fit_fwhm(data, xypos=xypos, fit_shape=(5, 5)) assert_allclose(fwhm[0], sources['fwhm'][0]) assert np.isnan(fwhm[1]) def test_fit_2dgaussian_generator_xypos(test_data): """ Test that xypos can be an iterator, such as a generator. """ data, sources = test_data xypos = ((x, y) for x, y in zip(sources['x_0'], sources['y_0'], strict=True)) fit = fit_2dgaussian(data, xypos=xypos, fit_shape=(5, 5)) fit_tbl = fit.results assert isinstance(fit_tbl, QTable) assert len(fit_tbl) == len(sources) 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 emits the fit_shape warning again 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 def test_fit_fwhm_convergence_warning_not_swallowed(): """ Test that the convergence warning is emitted even when the fit_shape warning also fired. """ data = np.ones((25, 25)) with pytest.warns(AstropyUserWarning) as record: fit_fwhm(data) warning_msgs = [str(w.message) for w in record] assert any('fit_shape is None' in msg for msg in warning_msgs) # Only the fit_fwhm convergence message is emitted, not the # PSFPhotometry one that refers to the results table convergence_msgs = [msg for msg in warning_msgs if 'may not have converged' in msg] assert len(convergence_msgs) == 1 assert '"xypos" and "fit_shape"' in convergence_msgs[0] def test_fit_2dgaussian_convergence_warning(): """ Test that fit_2dgaussian emits the PSFPhotometry convergence warning. """ data = np.ones((25, 25)) match = 'check the "flags" column in the output table' with pytest.warns(AstropyUserWarning, match=match): fit_2dgaussian(data, fit_shape=25, fix_fwhm=False) @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') # The 4 nearest unmasked neighbors are all equidistant (distance=1). # KD-tree tie-breaking is platform-dependent (e.g., win_arm64 picks # 45). assert data_int[5, 5] in (45, 54, 56, 65) 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_interpolate_missing_data_int_input(): """ Test that integer input data is interpolated in float. """ data = np.arange(100).reshape(10, 10) mask = np.ones_like(data, dtype=bool) # All pixels masked data_int = _interpolate_missing_data(data, mask, method='cubic') assert np.all(np.isnan(data_int)) # A masked corner pixel must be filled with a finite value # derived from its neighbors mask = np.zeros_like(data, dtype=bool) mask[0, 0] = True data_int = _interpolate_missing_data(data, mask, method='cubic') assert np.isfinite(data_int[0, 0]) assert 1 <= data_int[0, 0] <= 11 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 = Identity(2) # 2 inputs, but also 2 outputs 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 def test_fit_2dgaussian_flux_init_excludes_masked_pixels(): """ Regression test that the initial flux estimate excludes pixels masked by the user mask. """ yy, xx = np.mgrid[0:31, 0:31] data = CircularGaussianPRF(flux=50, x_0=15, y_0=15, fwhm=3.1)(xx, yy) data[15, 14] = 1e7 mask = np.zeros(data.shape, dtype=bool) mask[15, 14] = True fit = fit_2dgaussian(data, xypos=(15, 15), fit_shape=7, mask=mask, fix_fwhm=False) assert fit.results['flux_init'][0] < 100.0 assert_allclose(fit.results['flux_fit'][0], 50.0, rtol=0.01) assert_allclose(fit.results['fwhm_fit'][0], 3.1, atol=0.01) def test_fit_fwhm_reemits_other_warnings(): """ Regression test that fit_fwhm passes on nonconvergence warnings verbatim instead of replacing them with a false convergence warning. """ yy, xx = np.mgrid[0:31, 0:31] data = CircularGaussianPRF(flux=50, x_0=15, y_0=15, fwhm=3.1)(xx, yy) data[0, 0] = np.nan match = 'Input data contains unmasked nonfinite values' with pytest.warns(AstropyUserWarning, match=match) as record: fwhm = fit_fwhm(data, xypos=(15, 15), fit_shape=7) assert_allclose(fwhm[0], 3.1, atol=0.01) messages = [str(w.message) for w in record] assert not any('may not have converged' in msg for msg in messages) @pytest.mark.parametrize('bad_geometry', ['two_pixels', 'collinear']) def test_interpolate_missing_data_degenerate_geometry(bad_geometry): """ Regression test that cubic interpolation falls back to nearest neighbor when too few or collinear valid pixels remain. """ data = np.ones((5, 5)) mask = np.ones(data.shape, dtype=bool) if bad_geometry == 'two_pixels': mask[0, 0] = mask[4, 4] = False else: mask[2, :] = False result = _interpolate_missing_data(data, mask, method='cubic') assert np.all(np.isfinite(result)) astropy-photutils-24be529/photutils/psf/utils.py000066400000000000000000001073151526452531300221540ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for PSF-fitting photometry. """ import copy import warnings from collections.abc import Iterator import numpy as np from astropy.modeling import Model from astropy.nddata import NoOverlapError from astropy.table import QTable from astropy.units import Quantity from astropy.utils.exceptions import AstropyUserWarning from scipy import interpolate from scipy.spatial import QhullError from photutils.centroids import centroid_com from photutils.psf.flags import PSF_FLAGS from photutils.psf.functional_models import CircularGaussianPRF from photutils.utils import CutoutImage from photutils.utils._flags import define_flag_docstring from photutils.utils._parameters import as_pair __all__ = ['fit_2dgaussian', 'fit_fwhm'] def _copy_model_sharing_data(model): """ Return a copy of a model that shares every attribute but the model parameters and astropy's parameter and constraint state. The PSF image models use this for their `copy` methods so that the (possibly large) image data and the cached interpolators are shared by the copies that the fitters make for every source. The model parameters are copied, and so are the attributes in which astropy keeps the parameter array (``_parameters``), the model constraints (``_mconstraints``), and the constraints cache (``_constraints_cache``), which the fitters update in place. Sharing them would let copies fitted concurrently in several threads interfere with each other. The cache entries are bound to the model instance that created them, so the copy starts with an empty cache. The copy is made for every fitted source, so each attribute is copied in the cheapest way that isolates it. In particular, the parameters are copied shallowly, since a deep copy of a `~astropy.modeling.Parameter` is many times slower. The copied parameters are then bound to the copy of the model. Parameters ---------- model : `~astropy.modeling.Model` The model to copy. Returns ------- result : `~astropy.modeling.Model` The copy. """ new_model = object.__new__(model.__class__) param_names = model.param_names # Snapshot so concurrent cached_property fills cannot resize the # dict during iteration for key, val in dict(model.__dict__).items(): if key in param_names: param = copy.copy(val) # The parameter passes its model to the parameter validator param._model = new_model new_model.__dict__[key] = param elif key == '_parameters': new_model.__dict__[key] = val.copy() elif key == '_mconstraints': new_model.__dict__[key] = {name: list(items) for name, items in val.items()} elif key == '_constraints_cache': new_model.__dict__[key] = {} else: new_model.__dict__[key] = val return new_model def _make_mask(image, mask): """ Create a mask for the input image. Nonfinite values (e.g., NaN or inf) in the ``image`` array are automatically masked. If a mask is provided, then the nonfinite values are combined with the provided mask. Parameters ---------- image : 2D `~numpy.ndarray` The input image. mask : 2D bool `~numpy.ndarray` or None A boolean mask with the same shape as ``image``, where a `True` value indicates the corresponding element of ``image`` 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 nonfinite 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: combined_mask = finite_mask | mask if np.any(combined_mask & ~mask): warn_nonfinite() mask = combined_mask else: mask = finite_mask if np.any(finite_mask): warn_nonfinite() else: mask = None return mask def _out_of_grid_mask(xi, yi, shape): """ Compute a mask of positions outside an ePSF pixel grid. The bounds are the pixel-index domain of the ePSF image, ``[0, nx - 1] x [0, ny - 1]``. This helper defines the pixel-grid bounds used by both the image-based PSF model evaluations and their analytic Jacobians (``fit_deriv``), which must agree. Parameters ---------- xi, yi : `~numpy.ndarray` The (x, y) coordinates in the oversampled pixel frame of the ePSF image. shape : tuple of int The ``(ny, nx)`` shape of the ePSF image. Returns ------- mask : bool `~numpy.ndarray` A mask where `True` indicates the position is outside the ePSF pixel grid. """ ny, nx = shape return (xi < 0) | (xi > nx - 1) | (yi < 0) | (yi > ny - 1) 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. Nonfinite 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. Each element must be a positive odd number. If a scalar, then it is assumed to be a square. If `None`, then the shape of the input ``data`` is used, with any even dimension reduced by one pixel, and a warning is issued. ``fit_shape`` must be given when fitting more than one source. 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. The `~photutils.psf.CircularGaussianPRF` model is a Gaussian that is integrated over the pixels. Its FWHM is the FWHM of the Gaussian before that integration, which is smaller than the FWHM of the pixelated image of the source. The integration over a pixel adds 1/12 pixel**2 to the variance of the profile, so the FWHM of the pixelated image is approximately ``sqrt(fwhm**2 + 8 * ln(2) / 12)``, or ``sqrt(fwhm**2 + 0.462)``, in pixels. The two widths differ by about 3% for a FWHM of 3 pixels, 6% for 2 pixels, and 21% for 1 pixel. 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 >>> 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.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]) >>> 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 """ return _fit_2dgaussian(data, xypos=xypos, fwhm=fwhm, fix_fwhm=fix_fwhm, fit_shape=fit_shape, mask=mask, error=error) def _fit_2dgaussian(data, *, xypos=None, fwhm=None, fix_fwhm=True, fit_shape=None, mask=None, error=None, warn_no_convergence=True): """ Fit a 2D Gaussian model to one or more sources in an image. The parameters are the same as for `fit_2dgaussian`, with the addition of ``warn_no_convergence``. Parameters ---------- warn_no_convergence : bool, optional Whether the `~photutils.psf.PSFPhotometry` object emits its warning if one or more fits may not have converged. Returns ------- result : `~photutils.psf.PSFPhotometry` The PSF-fitting photometry results. """ # Prevent circular import from photutils.psf.photometry import PSFPhotometry # Mask nonfinite values mask = _make_mask(data, mask) if xypos is None: xypos = centroid_com(data, mask=mask) if isinstance(xypos, Iterator): # Consume iterators such as zip, map, and generators xypos = 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 noncutout 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]: try: cutout = CutoutImage(data, yxpos, tuple(fit_shape)) except NoOverlapError: # PSFPhotometry flags a source whose fit region is off the # image, so it only needs a finite placeholder here zero_flux = 0.0 if isinstance(data, Quantity): zero_flux <<= data.unit flux_init.append(zero_flux) continue good = np.isfinite(cutout.data) if mask is not None: mask_cutout = CutoutImage(mask, yxpos, tuple(fit_shape)) good &= ~mask_cutout.data flux_init.append(np.sum(cutout.data[good])) 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._warn_no_convergence = warn_no_convergence _ = 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. Nonfinite 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. Each element must be a positive odd number. If a scalar, then it is assumed to be a square. If `None`, then the shape of the input ``data`` is used, with any even dimension reduced by one pixel, and a warning is issued. ``fit_shape`` must be given when fitting more than one source. 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. The fitted FWHM values are constrained to be nonnegative. The value is NaN for a source that could not be fit (e.g., a fully masked source). Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If one or more fits may not have converged, or if ``fit_shape`` is `None`. 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. The `~photutils.psf.CircularGaussianPRF` model is a Gaussian that is integrated over the pixels. The returned FWHM is the FWHM of the Gaussian before that integration, which is smaller than the FWHM of the pixelated image of the source. The integration over a pixel adds 1/12 pixel**2 to the variance of the profile, so the FWHM of the pixelated image is approximately ``sqrt(fwhm**2 + 8 * ln(2) / 12)``, or ``sqrt(fwhm**2 + 0.462)``, in pixels. The two widths differ by about 3% for a FWHM of 3 pixels, 6% for 2 pixels, and 21% for 1 pixel. The fitter warnings are suppressed during the fits using the process-global warning filters, so warnings raised by other threads while this function runs may also be suppressed on non-free-threaded Python builds. 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 array([3.123]) Fit the FWHMs of multiple sources in an image: >>> import numpy as np >>> 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]) >>> xypos = zip(sources['x_0'], sources['y_0']) >>> fwhms = fit_fwhm(data, xypos=xypos, fit_shape=7) >>> fwhms array([5.69467204, 5.21376414, 7.65508658, 3.20255356, 6.66003098]) """ phot = _fit_2dgaussian(data, xypos=xypos, fwhm=fwhm, fix_fwhm=False, fit_shape=fit_shape, mask=mask, error=error, warn_no_convergence=False) # The PSFPhotometry warning refers to its results table, which is # not returned here flags = np.asarray(phot.results['flags']) if np.any(flags & PSF_FLAGS.NO_CONVERGENCE): 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 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.array(data, dtype=float) 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': try: interpol = interpolate.CloughTocher2DInterpolator(xy, z) except QhullError: # Too few or collinear valid pixels for triangulation interpol = interpolate.NearestNDInterpolator(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 an instance 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 ------- psf_model : `astropy.modeling.Model` The validated PSF model. Raises ------ TypeError If the PSF model is not an Astropy Model instance. 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. Otherwise, a `ValueError` is raised. The PSF model must be an instance 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`, `~astropy.units.Quantity`, or \ `~astropy.nddata.NDData` The 2D array on which to perform photometry. Invalid data values (i.e., NaN or inf) are automatically masked. If an `~astropy.nddata.NDData` object is input, its ``data``, ``mask``, ``uncertainty``, and ``unit`` attributes are used, and the ``mask`` and ``error`` keywords must not be set. 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. Nonboolean mask arrays are coerced with `~numpy.ndarray.astype`, so any nonzero value is treated as 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. The ``error`` values of the unmasked pixels that are fit must be finite and strictly positive. Otherwise, a `ValueError` is raised. 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 input, the table must include the initial x and y values. If `None`, the ``finder`` keyword must be defined and is used to find the sources. 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 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 compatible units. The table can also have an ``'id'`` column of unique source identification numbers. If it is not input, the sources are numbered from 1 in row order. The output table is sorted by ``'id'``. Returns ------- table : `~astropy.table.QTable` or `None` An astropy table with the PSF-fitting results. `None` is returned if no sources are found. If ``data`` has units, the ``'local_bkg'`` and flux columns have the same units. The columns are ordered as listed below, except that all of the ``_init`` columns come first, followed by all of the ``_fit`` columns and then all of the ``_err`` columns. 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} * ``'local_bkg'`` : the local background value subtracted from the data before the fit (zero if no local background was used) * ``'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 * ``'_init'``, ``'_fit'``, ``'_err'`` : the initial, fit, and error of any additional free model parameter, where ```` is the parameter name * ``'n_pixels_fit'`` : the number of unmasked pixels used to fit the source .. deprecated:: 3.0 The ``'npixfit'`` column name is deprecated and will be removed in version 4.0. Use ``'n_pixels_fit'`` instead. * ``'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 or outside the image, or that the source was not fit. 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'`` : The bitwise quality flags for the source. A warning is issued if one or more fits may not have converged. See `~photutils.psf.decode_psf_flags` for decoding flag values. The flags are: Notes ----- The ``qfit`` and ``cfit`` metrics are equivalent to the ``q`` and ``C`` fit 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 indent = ' ' * 12 suffix = (' *only for the first iteration*') iter_detected_column = (f"\n{indent}* ``'iter_detected'`` : " 'the iteration number in which the\n' f"{indent} source was detected") customized_docstring = base_docstring.format( init_params_suffix=suffix, iter_detected_column=iter_detected_column, ) else: # Customizations for PSFPhotometry customized_docstring = base_docstring.format( init_params_suffix='', iter_detected_column='', ) placeholder = '' if placeholder in customized_docstring: flag_descriptions = define_flag_docstring(PSF_FLAGS, indent=14) # 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-24be529/photutils/psf_matching/000077500000000000000000000000001526452531300223055ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf_matching/__init__.py000066400000000000000000000004401526452531300244140ustar00rootroot00000000000000# 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-24be529/photutils/psf_matching/fourier.py000066400000000000000000000511151526452531300243350ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for matching PSFs using Fourier methods. """ import numpy as np from scipy.fft import fft2, fftshift, ifft2 from photutils.psf_matching.utils import (_apply_window_to_fourier, _convert_psf_to_otf, _normalize_kernel, _validate_kernel_inputs) from photutils.utils._deprecation import deprecated __all__ = ['create_matching_kernel', 'make_kernel', 'make_wiener_kernel'] def _build_penalty_array(penalty): """ Validate the penalty input and convert it to a 2D float array. Parameters ---------- penalty : `None`, ``'laplacian'``, ``'biharmonic'``, or 2D array_like The regularization penalty operator. Returns ------- penalty_array : 2D `~numpy.ndarray` or `None` The penalty operator as a float array, or `None` if ``penalty`` is `None`. Raises ------ ValueError If ``penalty`` is an invalid string, cannot be converted to a numeric array, is not 2D, has even dimensions, contains NaN or Inf values, or is all zeros. """ if penalty is None: return None if isinstance(penalty, str): if penalty == 'laplacian': return np.array([[+0, -1, +0], [-1, +4, -1], [+0, -1, +0]], dtype=float) if penalty == 'biharmonic': return 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) msg = (f'Invalid penalty string {penalty!r}. ' 'Must be "laplacian" or "biharmonic".') raise ValueError(msg) try: penalty_array = np.asarray(penalty, dtype=float) except (TypeError, ValueError) as exc: msg = ("penalty must be None, 'laplacian', 'biharmonic', or a " '2D array.') raise ValueError(msg) from exc if penalty_array.ndim != 2: msg = 'penalty array must be 2D.' raise ValueError(msg) if (penalty_array.shape[0] % 2 == 0 or penalty_array.shape[1] % 2 == 0): msg = (f'penalty array must have odd dimensions, got shape ' f'{penalty_array.shape}.') raise ValueError(msg) if not np.all(np.isfinite(penalty_array)): msg = 'penalty array contains NaN or Inf values.' raise ValueError(msg) if np.all(penalty_array == 0): msg = ('penalty array must not be all zeros because it would ' 'apply no regularization.') raise ValueError(msg) return penalty_array def make_kernel(source_psf, target_psf, *, regularization=1e-4, window=None): """ 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 array_like 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. It is normalized to sum to 1 before the kernel is computed. target_psf : 2D array_like 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. It is normalized to sum to 1 before the kernel is computed. 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. 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 `~numpy.ndarray` 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 `. 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, contain NaN or Inf values, or have a zero sum, if ``regularization`` is not in the range [0, 1), if the window function output is invalid (not a 2D array, wrong shape, or values outside [0, 1]), or if the computed kernel is degenerate (nonfinite or sums to zero). 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))) return _normalize_kernel(kernel) 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. For a derivative operator such as the Laplacian, 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 PSF. 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 array_like 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. It is normalized to sum to 1 before the kernel is computed. target_psf : 2D array_like 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. It is normalized to sum to 1 before the kernel is computed. 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, while smaller values preserve more detail but may amplify noise. Must be a positive number. penalty : `None`, ``'laplacian'``, ``'biharmonic'``, or 2D \ array_like, 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 array_like: A custom penalty operator array. Its OTF will be computed and used in the denominator as :math:`|S|^2 + \\lambda \\cdot |P|^2`. The array must have odd dimensions in both axes, contain only finite values, and not be all zeros. 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 `~numpy.ndarray` 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, contain NaN or Inf values, have a zero sum, or are too small for the specified penalty, if ``regularization`` is not positive, if ``penalty`` is not a valid value (see above), if the window function output is invalid (not a 2D array, wrong shape, or values outside [0, 1]), or if the computed kernel is degenerate (nonfinite or sums to zero). 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) # The negated comparison also rejects NaN if not regularization > 0: msg = 'regularization must be a positive number.' raise ValueError(msg) penalty_array = _build_penalty_array(penalty) # 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. The # denominator can be zero at frequencies where both the source OTF # and the penalty OTF are zero. Any resulting nonfinite values are # caught by the kernel normalization below. with np.errstate(invalid='ignore', divide='ignore'): 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))) return _normalize_kernel(kernel) @deprecated(since='3.0', alternative='make_kernel', until='4.0') def create_matching_kernel(source_psf, target_psf, *, regularization=1e-4, window=None): """ Create a kernel to match 2D point spread functions (PSF). """ return make_kernel(source_psf, target_psf, window=window, regularization=regularization) astropy-photutils-24be529/photutils/psf_matching/tests/000077500000000000000000000000001526452531300234475ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf_matching/tests/__init__.py000066400000000000000000000000001526452531300255460ustar00rootroot00000000000000astropy-photutils-24be529/photutils/psf_matching/tests/conftest.py000066400000000000000000000020001526452531300256360ustar00rootroot00000000000000# 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 noncentered, 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-24be529/photutils/psf_matching/tests/test_fourier.py000066400000000000000000000626501526452531300265440ustar00rootroot00000000000000# 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 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning 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_window_nonsquare(self): """ Test that a window gives a kernel with the same width along both axes for nonsquare circular Gaussian PSFs. """ shape = (51, 101) yy, xx = np.mgrid[:shape[0], :shape[1]] y0, x0 = shape[0] // 2, shape[1] // 2 rr2 = (xx - x0) ** 2 + (yy - y0) ** 2 psf1 = np.exp(-rr2 / (2 * 2.0**2)) psf2 = np.exp(-rr2 / (2 * 3.0**2)) window = SplitCosineBellWindow(alpha=0.3, beta=0.1) kernel = make_kernel(psf1, psf2, window=window) x_sigma = np.sqrt(np.sum(kernel * (xx - x0) ** 2)) y_sigma = np.sqrt(np.sum(kernel * (yy - y0) ** 2)) assert_allclose(x_sigma, y_sigma, rtol=0.01) 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 noncallable 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 noncallable 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_nan_regularization(self, psf1, psf2): """ Test that NaN regularization raises ValueError. """ match = 'regularization must be a positive number' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, regularization=np.nan) 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 a penalty that cannot be converted to a numeric array raises ValueError. """ match = 'penalty must be None' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty={'bad': 1}) @pytest.mark.parametrize('penalty', [42, np.ones(5), [1.0, 2.0, 3.0]]) def test_penalty_non_2d_array(self, psf1, psf2, penalty): """ 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=penalty) def test_penalty_even_dimensions(self, psf1, psf2): """ Test that a penalty array with even dimensions raises ValueError. """ match = 'penalty array must have odd dimensions' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty=np.ones((2, 2))) def test_penalty_non_finite(self, psf1, psf2): """ Test that a penalty array with NaN or Inf values raises ValueError. """ penalty = np.zeros((3, 3)) penalty[1, 1] = np.nan match = 'penalty array contains NaN or Inf values' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty=penalty) penalty[1, 1] = np.inf with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty=penalty) def test_penalty_all_zeros(self, psf1, psf2): """ Test that an all-zero penalty array raises ValueError because it would apply no regularization. """ match = 'penalty array must not be all zeros' with pytest.raises(ValueError, match=match): make_wiener_kernel(psf1, psf2, penalty=np.zeros((3, 3))) def test_penalty_array_like(self, psf1, psf2): """ Test that an array-like penalty gives the same result as the equivalent ndarray penalty. """ laplacian = [[0, -1, 0], [-1, 4, -1], [0, -1, 0]] kernel_list = make_wiener_kernel(psf1, psf2, penalty=laplacian) kernel_arr = make_wiener_kernel(psf1, psf2, penalty=np.array(laplacian)) assert_allclose(kernel_list, kernel_arr) def test_non_finite_kernel_raises(self): """ Test that a degenerate Fourier-space denominator raises ValueError instead of silently returning a nonfinite kernel. A uniform source PSF and a uniform penalty operator both have OTFs that are zero away from the DC frequency, so the Wiener denominator is zero where the numerator is also zero (0/0). """ source_psf = np.ones((3, 3)) / 9.0 target_psf = _make_gaussian_psf(3, 1.0) penalty = np.ones((3, 3)) match = 'The computed kernel contains nonfinite values' with pytest.raises(ValueError, match=match): make_wiener_kernel(source_psf, target_psf, penalty=penalty) 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning): kernel_old = create_matching_kernel(psf1, psf2) kernel_new = make_kernel(psf1, psf2) assert_allclose(kernel_old, kernel_new) astropy-photutils-24be529/photutils/psf_matching/tests/test_thread_safety.py000066400000000000000000000062721526452531300277110ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the psf_matching tools are safe for concurrent use. The kernel-making functions are pure (they copy their inputs and hold no module-level mutable state) and the window classes are immutable after construction, so concurrent calls must produce results identical to serial calls and must not modify shared inputs. """ import threading from concurrent.futures import ThreadPoolExecutor from numpy.testing import assert_equal from photutils.psf_matching import (SplitCosineBellWindow, make_kernel, make_wiener_kernel, resize_psf) N_THREADS = 8 N_CALLS = 25 def _run_concurrently(task, n_threads=N_THREADS): """ Run ``task`` in ``n_threads`` threads and return all results. A barrier maximizes the overlap of the concurrent calls. """ barrier = threading.Barrier(n_threads) def worker(): barrier.wait() return [task() for _ in range(N_CALLS)] with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(worker) for _ in range(n_threads)] return [result for future in futures for result in future.result()] def test_concurrent_make_kernel(psf1, psf2): """ Test that concurrent make_kernel calls on shared input arrays return results identical to a serial call and do not modify the inputs. """ window = SplitCosineBellWindow(alpha=0.2, beta=0.3) psf1_orig = psf1.copy() psf2_orig = psf2.copy() expected = make_kernel(psf1, psf2, window=window) def task(): return make_kernel(psf1, psf2, window=window) for result in _run_concurrently(task): assert_equal(result, expected) assert_equal(psf1, psf1_orig) assert_equal(psf2, psf2_orig) def test_concurrent_make_wiener_kernel(psf1, psf2): """ Test that concurrent make_wiener_kernel calls with a penalty return results identical to a serial call. """ expected = make_wiener_kernel(psf1, psf2, penalty='laplacian') def task(): return make_wiener_kernel(psf1, psf2, penalty='laplacian') for result in _run_concurrently(task): assert_equal(result, expected) def test_concurrent_resize_psf(psf1): """ Test that concurrent resize_psf calls on a shared input array return results identical to a serial call and do not modify the input. """ psf1_orig = psf1.copy() expected = resize_psf(psf1, 0.1, 0.05) def task(): return resize_psf(psf1, 0.1, 0.05) for result in _run_concurrently(task): assert_equal(result, expected) assert_equal(psf1, psf1_orig) def test_concurrent_shared_window_instance(): """ Test that a single window instance shared across threads returns identical results for concurrent calls with different shapes. """ window = SplitCosineBellWindow(alpha=0.4, beta=0.3) shapes = [(25, 25), (51, 51), (25, 51), (51, 25)] expected = [window(shape) for shape in shapes] def task(): return [window(shape) for shape in shapes] for results in _run_concurrently(task): for result, expect in zip(results, expected, strict=True): assert_equal(result, expect) astropy-photutils-24be529/photutils/psf_matching/tests/test_utils.py000066400000000000000000000451531526452531300262300ustar00rootroot00000000000000# 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 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 nonzero 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 nonsquare 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, the output grid spans the 4 input pixels between the outermost pixel centers in 8 steps, which gives (9, 9). """ psf = _make_gaussian_psf(5, 1.5) result = resize_psf(psf, 0.1, 0.05) assert result.shape == (9, 9) 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 @pytest.mark.parametrize(('size', 'std', 'scale_in', 'scale_out'), [(101, 4.0, 0.25, 1.0), (101, 4.0, 0.25, 0.5), (51, 3.0, 0.5, 0.25), (51, 3.0, 1.0, 0.6), (201, 10.0, 0.1, 0.3), (41, 3.0, 0.3, 0.2)]) def test_pixel_scale(self, size, std, scale_in, scale_out): """ Test that the output has exactly the requested pixel scale. A well-sampled Gaussian that is resized must match the same Gaussian evaluated on a grid with the output pixel scale. """ psf = _make_gaussian_psf(size, std) result = resize_psf(psf, scale_in, scale_out) n_out = result.shape[0] offsets = (np.arange(n_out) - n_out // 2) * scale_out / scale_in assert np.abs(offsets).max() <= size // 2 profile = np.exp(-offsets**2 / (2 * std**2)) expected = np.outer(profile, profile) expected /= expected.sum() assert_allclose(result, expected, atol=1e-3 * expected.max()) # The second moment gives the width in output pixels xx = np.arange(n_out) - n_out // 2 marginal = result.sum(axis=0) sigma = np.sqrt(np.sum(marginal * xx**2) / marginal.sum()) assert_allclose(sigma, std * scale_in / scale_out, rtol=1e-3) def test_same_scale(self): """ Test that resizing to the same pixel scale returns the input. """ psf = _make_gaussian_psf(11, 2.0) result = resize_psf(psf, 0.1, 0.1) assert_allclose(result, psf, rtol=1e-12, atol=1e-15) def test_integer_downsample(self): """ Test that an integer change of the pixel scale samples the input at every nth pixel from its center. """ psf = _make_gaussian_psf(101, 6.0) result = resize_psf(psf, 0.25, 1.0) assert result.shape == (25, 25) expected = psf[2::4, 2::4] expected = expected / expected.sum() * psf.sum() assert_allclose(result, expected, rtol=1e-10, atol=1e-15) def test_within_input(self): """ Test that the output grid ends at the outermost pixel centers of the input, so that no output value is extrapolated or padded. """ psf = np.ones((25, 25)) result = resize_psf(psf, 1.0, 0.25) # The input pixel centers span -12 to 12 pixels from the # center, which is 96 steps of the output pixel scale assert result.shape == (97, 97) assert_allclose(result, psf.sum() / 97**2, rtol=1e-10) # A round-trip returns the input shape result2 = resize_psf(result, 0.25, 1.0) assert result2.shape == psf.shape @pytest.mark.parametrize(('size', 'scale_out', 'n_out'), [(25, 0.3, 81), (25, 3.0, 9), (25, 13.0, 1), (11, 0.7, 15), (1, 0.5, 1), (1, 2.0, 1)]) def test_output_size(self, size, scale_out, n_out): """ Test the output size for pixel scale ratios that are not integers and for sizes that reduce to a single pixel. """ psf = _make_gaussian_psf(size, max(size / 8, 1.0)) result = resize_psf(psf, 1.0, scale_out) assert result.shape == (n_out, n_out) assert_allclose(result.sum(), psf.sum()) @pytest.mark.parametrize('order', [0, 1, 2, 3, 4, 5]) @pytest.mark.parametrize('scale_out', [0.03, 0.05, 0.07, 0.1, 0.13, 0.2, 0.31]) def test_centered(self, order, scale_out): """ Test that the output is symmetric about the central output pixel for every spline order. """ psf = _make_gaussian_psf(31, 2.5) result = resize_psf(psf, 0.1, scale_out, order=order) assert_allclose(result, result[::-1, ::-1], rtol=1e-10, atol=1e-15) assert_allclose(result, result.T, rtol=1e-10, atol=1e-15) # The peak is unique only for the interpolating orders. # Nearest-neighbor interpolation can repeat the central value. center = result.shape[0] // 2 assert result[center, center] == result.max() if order > 0: idx = np.unravel_index(np.argmax(result), result.shape) assert idx == (center, center) @pytest.mark.parametrize(('size', 'scale_in', 'scale_out', 'n_out'), [(11, 0.022, 0.01, 23), (51, 0.022, 0.025, 45), (11, 0.022, 0.055, 5), (51, 0.022, 0.55, 3)]) def test_whole_number_size(self, size, scale_in, scale_out, n_out): """ Test that roundoff in a half size that is a whole number does not remove pixels from the output. """ psf = _make_gaussian_psf(size, size / 8) result = resize_psf(psf, scale_in, scale_out) assert result.shape == (n_out, n_out) def test_quantity_pixel_scales(self): """ Test pixel scales that are quantities with different units. """ psf = _make_gaussian_psf(11, 2.0) expected = resize_psf(psf, 0.1, 0.05) result = resize_psf(psf, 0.1 * u.arcsec, 50 * u.mas) assert isinstance(result, np.ndarray) assert_allclose(result, expected, rtol=1e-12, atol=1e-15) with pytest.raises(TypeError): resize_psf(psf, 0.1 * u.arcsec, 0.05 * u.pix) def test_zero_sum_output(self): """ Test that a resized PSF with a zero sum raises ValueError. """ psf = np.zeros((5, 5)) psf[0, 0] = 1.0 match = 'resized PSF has a zero sum' with pytest.raises(ValueError, match=match): resize_psf(psf, 1.0, 3.0, order=1) @pytest.mark.parametrize('order', [-1, 6, 1.5, 3.0, '3', None]) def test_invalid_order(self, order): """ Test that an order that is not an integer from 0 to 5 raises ValueError. """ psf = _make_gaussian_psf(11, 2.0) match = 'order must be an integer from 0 to 5' with pytest.raises(ValueError, match=match): resize_psf(psf, 0.1, 0.05, order=order) def test_rectangular(self): """ Test a PSF with different sizes along the two axes. """ yy, xx = np.mgrid[-10:11, -20:21] psf = np.exp(-(xx**2 + yy**2) / (2 * 3.0**2)) psf /= psf.sum() result = resize_psf(psf, 1.0, 0.5) assert result.shape == (41, 81) yy, xx = np.mgrid[-20:21, -40:41] / 2 expected = np.exp(-(xx**2 + yy**2) / (2 * 3.0**2)) expected /= expected.sum() assert_allclose(result, expected, atol=1e-3 * expected.max()) 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-24be529/photutils/psf_matching/tests/test_windows.py000066400000000000000000000124431526452531300265560ustar00rootroot00000000000000# 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_nonsquare_shape(): """ Test that the window radii scale with the size of each axis for a nonsquare shape. """ alpha = 0.4 ny, nx = 51, 101 data = TukeyWindow(alpha=alpha)((ny, nx)) assert_allclose(data[ny // 2, :], tukey(nx, alpha=alpha), atol=1e-14) assert_allclose(data[:, nx // 2], tukey(ny, alpha=alpha), atol=1e-14) # The window is zero outside of the ellipse inscribed in the array yy, xx = np.mgrid[:ny, :nx] dist = np.hypot((xx - nx // 2) / (nx // 2), (yy - ny // 2) / (ny // 2)) assert np.all(data[dist > 1] == 0) assert np.all(data[dist < 1 - alpha] == 1) def test_single_pixel_axis(): """ Test that an axis of size 1 gives the 1D window along the other axis. """ data = TukeyWindow(alpha=0.5)((1, 11)) assert_allclose(data[0], tukey(11, alpha=0.5), atol=1e-14) 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-24be529/photutils/psf_matching/utils.py000066400000000000000000000336631526452531300240320ustar00rootroot00000000000000# 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 map_coordinates __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, contain NaN or Inf values, or have a zero sum. TypeError If ``window`` is not `None` and 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 a finite 2D array with odd dimensions and a nonzero sum. 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, contains NaN or Inf values, or has a zero sum. """ 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 nonzero 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 _normalize_kernel(kernel): """ Normalize a matching kernel so that it sums to 1. Parameters ---------- kernel : 2D `~numpy.ndarray` The matching kernel. Returns ------- kernel : 2D `~numpy.ndarray` The normalized matching kernel. Raises ------ ValueError If the kernel contains nonfinite values or its sum is zero or nearly zero. """ kernel_sum = np.sum(kernel) if not np.isfinite(kernel_sum): msg = ('The computed kernel contains nonfinite values. This ' 'can occur when the Fourier-space denominator is zero ' 'at frequencies where the numerator is also zero (e.g., ' 'the source OTF and the penalty OTF are both zero at ' 'the same frequency).') raise ValueError(msg) if np.isclose(kernel_sum, 0.0): msg = ('The computed kernel sums to zero, which likely indicates ' 'that the regularization is too aggressive or that the ' 'window function suppressed all frequencies. Try reducing ' 'the regularization parameter or using a different window ' 'function.') raise ValueError(msg) return kernel / kernel_sum 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 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) if np.all(psf == 0): return np.zeros(shape, dtype=complex) 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 PSF is interpolated at the points of a grid with the output pixel scale that is centered on the central pixel of the input PSF. The total flux of the PSF is conserved during the resizing. Parameters ---------- psf : 2D array_like 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 or `~astropy.units.Quantity` The pixel scale of the input ``psf``. If a float, the units must match ``output_pixel_scale``. If a `~astropy.units.Quantity`, the units must be convertible to those of ``output_pixel_scale``. output_pixel_scale : float or `~astropy.units.Quantity` The pixel scale of the output ``psf``. If a float, the units must match ``input_pixel_scale``. If a `~astropy.units.Quantity`, the units must be convertible to those of ``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, with a pixel scale of ``output_pixel_scale``. The output always has odd dimensions, which guarantees that it is centered and usable for PSF matching. The size along each axis is ``2 * floor((input_size - 1) / 2 * (input_pixel_scale / output_pixel_scale)) + 1``. This is the largest odd size for which the output grid lies within the outermost pixel centers of the input PSF, so no output value is extrapolated. Raises ------ ValueError If ``psf`` is not a 2D array, has even dimensions, contains NaN or Inf values, or has a zero sum, if the pixel scales are not positive, if ``order`` is not an integer from 0 to 5, or if the resized PSF has a zero sum. TypeError If the pixel scales have units that are not convertible to each other, or if only one of them is a `~astropy.units.Quantity`. Notes ----- This function changes only the pixel scale. The resized PSF generally does not have the same shape as the PSF it will be matched to, so the two PSFs may still need to be cropped or padded to a common shape before computing a matching kernel. The PSF is interpolated and is not integrated over the output pixels. If the two PSFs come from images with different detector pixel sizes, each PSF should already include the pixel integration of its own image. An oversampled PSF that is sampled at points, such as the output of an optical model, should first be integrated over the detector pixels with :func:`~photutils.psf.make_epsf_from_psf`. The input PSF should be well sampled at both the input and output pixel scales. Resizing a PSF to a larger pixel scale samples it without any smoothing. If the PSF is undersampled at the output pixel scale, the result is aliased and a matching kernel computed from it will be inaccurate. A spline is also a poor interpolant for a PSF that is undersampled at the input pixel scale. """ 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') if not isinstance(order, (int, np.integer)) or not 0 <= order <= 5: msg = 'order must be an integer from 0 to 5' raise ValueError(msg) # The conversion to a float handles pixel scales that are quantities # with different units. ratio = float(input_pixel_scale / output_pixel_scale) # The output grid is centered on the central input pixel and its # spacing is exactly the output pixel scale. Its size is the # largest odd size that keeps the grid within the outermost input # pixel centers, so nothing is extrapolated. The rounding keeps # roundoff in a half size that is a whole number from removing a # pixel. coords = [] for n_in in psf.shape: center = (n_in - 1) / 2 half_size = int(np.floor(np.round(center * ratio, 9))) offsets = np.arange(-half_size, half_size + 1) / ratio if order == 0: # Nearest-neighbor interpolation rounds a point midway # between two input pixels in one direction. Moving the # points slightly toward the center keeps the output # symmetric. offsets *= 1 - 1e-9 coords.append(center + offsets) result = map_coordinates(psf, np.meshgrid(*coords, indexing='ij'), order=order, mode='nearest') result_sum = result.sum() if result_sum == 0: msg = 'The resized PSF has a zero sum and cannot be normalized.' raise ValueError(msg) # Normalize the PSF to conserve total flux after resizing. return result * (psf.sum() / result_sum) astropy-photutils-24be529/photutils/psf_matching/windows.py000066400000000000000000000354071526452531300243620ustar00rootroot00000000000000# 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 _normalized_distance_grid(shape): """ Return an array where each value is the distance from the array center in units of the half size of the array along each axis. The distance is 1.0 at the centers of the edge pixels along the central row and column, so the contours of constant distance are ellipses for a nonsquare shape. 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 normalized 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]] # An axis of size 1 has a zero half size and its only pixel is at # the center return np.hypot((x_vals - x_cen) / (x_cen or 1.0), (y_vals - y_cen) / (y_cen or 1.0)) 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 The width of the cosine taper as a fraction of the maximum radius. The maximum radius is the distance in pixels from the array center to the edge, which is ``(n - 1) / 2`` along an axis of size ``n``. For a nonsquare shape, it differs between the two axes, so the window is elliptical. ``alpha`` must be between 0.0 and 1.0, inclusive. beta : float The radius of the inner flat region as a fraction of the maximum radius. The taper begins beyond this radius. ``beta`` must be between 0.0 and 1.0, inclusive. Raises ------ ValueError If ``alpha`` or ``beta`` is not between 0.0 and 1.0, inclusive. Warns ----- `~astropy.utils.exceptions.AstropyUserWarning` If ``alpha + beta`` is greater than 1.0. The taper then extends beyond the maximum radius and is truncated by the array edges. 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, so the taper extends beyond the ' 'maximum radius and is truncated by the array edges.') 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 shape of the output array. It must have two elements. The window radii are defined relative to ``(n - 1) / 2`` along each axis of size ``n``, so the window is elliptical for a nonsquare shape. When the window is applied to a Fourier transform, it then has the same cutoff in spatial frequency along both axes. Returns ------- result : 2D `~numpy.ndarray` The window function as a 2D array. Raises ------ ValueError If ``shape`` does not have two elements. """ # The distances are in units of the maximum radius along each # axis dist = _normalized_distance_grid(shape) r_inner = self.beta taper_width = self.alpha 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 suppression of ringing artifacts in the matching kernel 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__}()' 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 The width of the cosine taper as a fraction of the maximum radius. The maximum radius is the distance in pixels from the array center to the edge, which is ``(n - 1) / 2`` along an axis of size ``n``. For a nonsquare shape, it differs between the two axes, so the window is elliptical. Must be between 0.0 and 1.0, inclusive. When ``alpha=0``, this becomes a `TopHatWindow`. When ``alpha=1``, this becomes a `HanningWindow`. Raises ------ ValueError If ``alpha`` is not between 0.0 and 1.0, inclusive. 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})') 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. The window is zero beyond a radius of ``alpha`` times the maximum radius, so a small ``alpha`` removes all but the lowest spatial frequencies. For the same ``alpha`` value, it suppresses much more high-frequency noise than `TukeyWindow`, which equals 1.0 out to a radius of ``1 - alpha`` times the maximum radius. The cost is that it also removes more of the high-frequency signal. Use this window when you want a smooth low-pass taper with no flat inner region. Parameters ---------- alpha : float The width of the cosine taper as a fraction of the maximum radius. The maximum radius is the distance in pixels from the array center to the edge, which is ``(n - 1) / 2`` along an axis of size ``n``. For a nonsquare shape, it differs between the two axes, so the window is elliptical. Must be between 0.0 and 1.0, inclusive. When ``alpha=1``, this becomes a `HanningWindow`. When ``alpha=0``, the window is zero everywhere except at the central pixel of an array with odd dimensions. Raises ------ ValueError If ``alpha`` is not between 0.0 and 1.0, inclusive. 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})') class TopHatWindow(SplitCosineBellWindow): """ Class to define a 2D top hat window function. This window equals 1.0 inside a circular region defined by ``beta`` (elliptical for a nonsquare shape) 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 the matching kernel. 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 The radius of the window as a fraction of the maximum radius. The maximum radius is the distance in pixels from the array center to the edge, which is ``(n - 1) / 2`` along an axis of size ``n``. For a nonsquare shape, it differs between the two axes, so the window is elliptical. The window is zero beyond the window radius. Must be between 0.0 and 1.0, inclusive. Raises ------ ValueError If ``beta`` is not 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})') astropy-photutils-24be529/photutils/resources/000077500000000000000000000000001526452531300216555ustar00rootroot00000000000000astropy-photutils-24be529/photutils/resources/__init__.py000066400000000000000000000000001526452531300237540ustar00rootroot00000000000000astropy-photutils-24be529/photutils/resources/manifests/000077500000000000000000000000001526452531300236465ustar00rootroot00000000000000astropy-photutils-24be529/photutils/resources/manifests/photutils-1.0.0.yaml000066400000000000000000000111661526452531300272240ustar00rootroot00000000000000id: asdf://astropy.org/photutils/manifests/photutils-1.0.0 extension_uri: asdf://astropy.org/photutils/extensions/photutils-1.0.0 title: Photutils extension 1.0.0 description: |- A set of tags for serializing photutils objects. tags: - tag_uri: tag:astropy.org:photutils/aperture/circular_aperture-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/aperture/circular_aperture-1.0.0 title: Circular aperture description: A circular aperture in pixel or sky coordinates. - tag_uri: tag:astropy.org:photutils/aperture/circular_annulus-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/aperture/circular_annulus-1.0.0 title: Circular annulus aperture description: A circular annulus aperture in pixel or sky coordinates. - tag_uri: tag:astropy.org:photutils/aperture/elliptical_aperture-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/aperture/elliptical_aperture-1.0.0 title: Elliptical aperture description: An elliptical aperture in pixel or sky coordinates. - tag_uri: tag:astropy.org:photutils/aperture/elliptical_annulus-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/aperture/elliptical_annulus-1.0.0 title: Elliptical annulus aperture description: An elliptical annulus aperture in pixel or sky coordinates. - tag_uri: tag:astropy.org:photutils/aperture/polygon_aperture-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/aperture/polygon_aperture-1.0.0 title: Polygon aperture description: A polygon aperture in pixel or sky coordinates. - tag_uri: tag:astropy.org:photutils/aperture/rectangular_aperture-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/aperture/rectangular_aperture-1.0.0 title: Rectangular aperture description: A rectangular aperture in pixel or sky coordinates. - tag_uri: tag:astropy.org:photutils/aperture/rectangular_annulus-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/aperture/rectangular_annulus-1.0.0 title: Rectangular annulus aperture description: A rectangular annulus aperture in pixel or sky coordinates. - tag_uri: tag:astropy.org:photutils/psf/airy_disk_prf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/airy_disk_prf-1.0.0 title: AiryDiskPRF description: A 2D Airy disk PSF model integrated over pixels. - tag_uri: tag:astropy.org:photutils/psf/airy_disk_psf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/airy_disk_psf-1.0.0 title: AiryDiskPSF description: A 2D Airy disk PSF model. - tag_uri: tag:astropy.org:photutils/psf/circular_gaussian_prf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/circular_gaussian_prf-1.0.0 title: CircularGaussianPRF description: A circular 2D Gaussian PRF model. - tag_uri: tag:astropy.org:photutils/psf/circular_gaussian_psf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/circular_gaussian_psf-1.0.0 title: CircularGaussianPSF description: A circular 2D Gaussian PSF model. - tag_uri: tag:astropy.org:photutils/psf/circular_gaussian_sigma_prf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/circular_gaussian_sigma_prf-1.0.0 title: CircularGaussianSigmaPRF description: A circular 2D Gaussian PRF model parameterized by sigma. - tag_uri: tag:astropy.org:photutils/psf/gaussian_prf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/gaussian_prf-1.0.0 title: GaussianPRF description: A 2D Gaussian PRF model. - tag_uri: tag:astropy.org:photutils/psf/gaussian_psf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/gaussian_psf-1.0.0 title: GaussianPSF description: A 2D Gaussian PSF model. - tag_uri: tag:astropy.org:photutils/psf/gridded_psf_model-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/gridded_psf_model-1.0.0 title: GriddedPSFModel description: A grid of 2D ePSF models. - tag_uri: tag:astropy.org:photutils/psf/image_psf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/image_psf-1.0.0 title: ImagePSF description: A 2D image PSF model. - tag_uri: tag:astropy.org:photutils/psf/moffat_prf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/moffat_prf-1.0.0 title: MoffatPRF description: A 2D Moffat PSF model integrated over pixels. - tag_uri: tag:astropy.org:photutils/psf/moffat_psf-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/moffat_psf-1.0.0 title: MoffatPSF description: A 2D Moffat PSF model. - tag_uri: tag:astropy.org:photutils/psf/stdpsf_grid-1.0.0 schema_uri: asdf://astropy.org/photutils/schemas/psf/stdpsf_grid-1.0.0 title: STDPSFGrid description: A grid of 2D ePSF models in the STDPSF format. astropy-photutils-24be529/photutils/resources/schemas/000077500000000000000000000000001526452531300233005ustar00rootroot00000000000000astropy-photutils-24be529/photutils/resources/schemas/aperture/000077500000000000000000000000001526452531300251275ustar00rootroot00000000000000astropy-photutils-24be529/photutils/resources/schemas/aperture/circular_annulus-1.0.0.yaml000066400000000000000000000073701526452531300320250ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/aperture/circular_annulus-1.0.0' title: Circular annulus aperture description: | ASDF schema for serializing a circular annulus aperture with one or more positions, in pixel or sky coordinates. examples: - - | A CircularAnnulus aperture with a single position, an inner radius of 3 pixels, and an outer radius of 5.1 pixels. - | ! positions: [10.0, 20.0] r_in: 3.0 r_out: 5.1 - - | A SkyCircularAnnulus aperture with two positions, an inner radius of 0.5 arcsec, and an outer radius of 1 arcsec. - | ! positions: ! dec: ! unit: !unit/unit-1.0.0 deg value: !core/ndarray-1.1.0 source: 1 datatype: float64 byteorder: little shape: [2] frame: icrs ra: ! unit: !unit/unit-1.0.0 deg value: !core/ndarray-1.1.0 source: 0 datatype: float64 byteorder: little shape: [2] wrap_angle: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 360.0 representation_type: spherical r_in: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 0.5 r_out: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 1.0 type: object properties: positions: title: Aperture position(s). description: | The coordinates of the aperture center(s) in one of the following formats. If a CircularAnnulus in pixel coordinates: * single ``(x, y)`` pair as an array * `numpy.ndarray` of ``(x, y)`` pairs If a SkyCircularAnnulus: * `astropy.coordinates.SkyCoord` object anyOf: - type: array items: type: number maxItems: 2 minItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' r_in: title: Inner radius. description: | CircularAnnulus: the inner radius of the circular annulus in pixels. SkyCircularAnnulus: the inner radius of the circular annulus on the sky in angular units. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' r_out: title: Outer radius. description: | CircularAnnulus: the outer radius of the circular annulus in pixels. SkyCircularAnnulus: the outer radius of the circular annulus on the sky in angular units. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' required: [positions, r_in, r_out] additionalProperties: false # CircularAnnulus and SkyCircularAnnulus share a tag, so this requires # a file to use pixel values or angular quantities consistently. oneOf: - title: Pixel coordinates properties: positions: anyOf: - type: array - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' r_in: type: number r_out: type: number - title: Sky coordinates properties: positions: tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' r_in: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' r_out: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' ... astropy-photutils-24be529/photutils/resources/schemas/aperture/circular_aperture-1.0.0.yaml000066400000000000000000000061051526452531300321620ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/aperture/circular_aperture-1.0.0' title: Circular aperture description: | ASDF schema for serializing a circular aperture with one or more positions, in pixel or sky coordinates. examples: - - | A CircularAperture with a single position and a radius of 5 pixels. - | ! positions: [1.0, 2.0] r: 5.0 - - | A SkyCircularAperture with two positions and a radius of 5 arcsec. - | ! positions: ! dec: ! unit: !unit/unit-1.0.0 deg value: !core/ndarray-1.1.0 source: 1 datatype: float64 byteorder: little shape: [2] frame: icrs ra: ! unit: !unit/unit-1.0.0 deg value: !core/ndarray-1.1.0 source: 0 datatype: float64 byteorder: little shape: [2] wrap_angle: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 360.0 representation_type: spherical r: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 5.0 type: object properties: positions: title: Aperture position(s). description: | The coordinates of the aperture center(s) in one of the following formats. If a CircularAperture in pixel coordinates: * single ``(x, y)`` pair as an array * `numpy.ndarray` of ``(x, y)`` pairs If a SkyCircularAperture: * `astropy.coordinates.SkyCoord` object anyOf: - type: array items: type: number maxItems: 2 minItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' r: title: Aperture radius. description: | If ``CircularAperture`` - the radius of the circular aperture in pixels. If ``SkyCircularAperture`` - the radius of the aperture on the sky in angular units. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' required: [positions, r] additionalProperties: false # CircularAperture and SkyCircularAperture share a tag, so this requires # a file to use pixel values or angular quantities consistently. oneOf: - title: Pixel coordinates properties: positions: anyOf: - type: array - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' r: type: number - title: Sky coordinates properties: positions: tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' r: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' ... astropy-photutils-24be529/photutils/resources/schemas/aperture/elliptical_annulus-1.0.0.yaml000066400000000000000000000135251526452531300323420ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/aperture/elliptical_annulus-1.0.0' title: Elliptical annulus aperture description: | ASDF schema for serializing an elliptical annulus aperture with one or more positions, in pixel or sky coordinates. examples: - - | An EllipticalAnnulus aperture with a single position, inner semimajor and semiminor axes of 0.5 and 0.25 pixels, outer semimajor and semiminor axes of 2 and 1 pixels, and a rotation angle of 0 radians. - | ! a_in: 0.5 a_out: 2.0 b_in: 0.25 b_out: 1.0 positions: [10.0, 20.0] theta: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 rad value: 0.0 - - | A SkyEllipticalAnnulus aperture with a single position, inner semimajor and semiminor axes of 0.5 and 0.25 arcsec, outer semimajor and semiminor axes of 2 and 1 arcsec, and a position angle of 0 degrees. - | ! a_in: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 0.5 a_out: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 2.0 b_in: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 0.25 b_out: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 1.0 positions: ! dec: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 30.0 frame: icrs ra: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 10.0 wrap_angle: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 360.0 representation_type: spherical theta: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 deg value: 0.0 type: object properties: positions: title: Aperture position(s). description: | The coordinates of the aperture center(s) in one of the following formats. If a EllipticalAnnulus in pixel coordinates: * single ``(x, y)`` pair as an array * `numpy.ndarray` of ``(x, y)`` pairs If a SkyEllipticalAnnulus: * `astropy.coordinates.SkyCoord` object anyOf: - type: array items: type: number maxItems: 2 minItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' a_in: title: Inner semimajor axis. description: | The inner semimajor axis of the elliptical annulus. EllipticalAnnulus: in pixels SkyEllipticalAnnulus: in angular units anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' a_out: title: Outer semimajor axis. description: | The outer semimajor axis of the elliptical annulus. EllipticalAnnulus: in pixels SkyEllipticalAnnulus: in angular units anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' b_in: title: Inner semiminor axis. description: | The inner semiminor axis of the elliptical annulus. EllipticalAnnulus: in pixels SkyEllipticalAnnulus: in angular units anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' b_out: title: Outer semiminor axis. description: | The outer semiminor axis of the elliptical annulus. EllipticalAnnulus: in pixels SkyEllipticalAnnulus: in angular units anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: title: Rotation angle. description: | EllipticalAnnulus: the rotation angle of the ellipse from the positive x-axis, as an angular quantity or as a number in radians. The rotation angle increases counterclockwise. SkyEllipticalAnnulus: 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). anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' required: [positions, a_in, a_out, b_out] additionalProperties: false # EllipticalAnnulus and SkyEllipticalAnnulus share a tag, so this # requires a file to use pixel values or angular quantities # consistently. ``theta`` is constrained only in the sky branch because # EllipticalAnnulus also accepts an angular quantity. oneOf: - title: Pixel coordinates properties: positions: anyOf: - type: array - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' a_in: type: number a_out: type: number b_in: type: number b_out: type: number - title: Sky coordinates properties: positions: tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' a_in: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' a_out: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' b_in: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' b_out: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' ... astropy-photutils-24be529/photutils/resources/schemas/aperture/elliptical_aperture-1.0.0.yaml000066400000000000000000000112351526452531300325000ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/aperture/elliptical_aperture-1.0.0' title: Elliptical aperture description: | ASDF schema for serializing an elliptical aperture with one or more positions, in pixel or sky coordinates. examples: - - | An EllipticalAperture with a single position, a semimajor axis of 5 pixels, a semiminor axis of 3 pixels, and a rotation angle of 0.2 radians. - | ! a: 5.0 b: 3.0 positions: [20.0, 30.0] theta: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 rad value: 0.2 - - | A SkyEllipticalAperture with a single position, a semimajor axis of 5 arcsec, a semiminor axis of 3 arcsec, and a position angle of 0 degrees. - | ! a: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 5.0 b: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 3.0 positions: ! dec: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 30.0 frame: icrs ra: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 10.0 wrap_angle: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 360.0 representation_type: spherical theta: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 deg value: 0.0 type: object properties: positions: title: Aperture position(s). description: | The coordinates of the aperture center(s) in one of the following formats. If a EllipticalAperture in pixel coordinates: * single ``(x, y)`` pair as an array * `numpy.ndarray` of ``(x, y)`` pairs If a SkyEllipticalAperture: * `astropy.coordinates.SkyCoord` object anyOf: - type: array items: type: number maxItems: 2 minItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' a: title: Semimajor axis. description: | EllipticalAperture: the semimajor axis of the ellipse in pixels. SkyEllipticalAperture: the semimajor axis of the ellipse in angular units. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' b: title: Semiminor axis. description: | EllipticalAperture: the semiminor axis of the ellipse in pixels. SkyEllipticalAperture: the semiminor axis of the ellipse in angular units. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: title: Rotation angle. description: | EllipticalAperture: the rotation angle of the ellipse from the positive x-axis, as an angular quantity or as a number in radians. The rotation angle increases counterclockwise. SkyEllipticalAperture: 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). anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' required: [positions, a, b] additionalProperties: false # EllipticalAperture and SkyEllipticalAperture share a tag, so this # requires a file to use pixel values or angular quantities # consistently. ``theta`` is constrained only in the sky branch because # EllipticalAperture also accepts an angular quantity. oneOf: - title: Pixel coordinates properties: positions: anyOf: - type: array - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' a: type: number b: type: number - title: Sky coordinates properties: positions: tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' a: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' b: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' ... astropy-photutils-24be529/photutils/resources/schemas/aperture/polygon_aperture-1.0.0.yaml000066400000000000000000000067651526452531300320610ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/aperture/polygon_aperture-1.0.0' title: Polygon aperture description: | ASDF schema for serializing a polygon aperture with one or more positions, in pixel or sky coordinates. examples: - - | A PolygonAperture with a single position and four vertex offsets in pixels. - | ! positions: [10.0, 20.0] vertex_offsets: !core/ndarray-1.1.0 source: 0 datatype: float64 byteorder: little shape: [4, 2] - - | A SkyPolygonAperture with a single position and five vertex offsets in arcsec. - | ! positions: ! dec: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 30.0 frame: icrs ra: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 10.0 wrap_angle: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 360.0 representation_type: spherical vertex_offsets: !unit/quantity-1.3.0 unit: !unit/unit-1.0.0 arcsec value: !core/ndarray-1.1.0 source: 0 datatype: float64 byteorder: little shape: [5, 2] type: object properties: positions: title: Aperture position(s). description: | The coordinates of the aperture center(s) in one of the following formats. If a PolygonAperture in pixel coordinates: * single ``(x, y)`` pair as an array * `numpy.ndarray` of ``(x, y)`` pairs If a SkyPolygonAperture: * `astropy.coordinates.SkyCoord` object anyOf: - type: array items: type: number maxItems: 2 minItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' vertex_offsets: title: Offsets of the polygon vertices from the positions. description: | The offsets of each polygon vertex relative to ``positions``. The polygon must be simple (non-self-intersecting) with at least 3 vertices. The polygon may be convex or nonconvex. PolygonAperture: The offsets are in pixel space. SkyPolygonAperture: The offsets are in angular units and are applied as spherical offsets on the local tangent plane. anyOf: - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' required: [positions, vertex_offsets] additionalProperties: false # PolygonAperture and SkyPolygonAperture share a tag, so this requires # a file to use pixel values or angular quantities consistently. oneOf: - title: Pixel coordinates properties: positions: anyOf: - type: array - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' vertex_offsets: tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - title: Sky coordinates properties: positions: tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' vertex_offsets: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' ... astropy-photutils-24be529/photutils/resources/schemas/aperture/rectangular_annulus-1.0.0.yaml000066400000000000000000000147671526452531300325400ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/aperture/rectangular_annulus-1.0.0' title: Rectangular annulus aperture description: | ASDF schema for serializing a rectangular annulus aperture with one or more positions, in pixel or sky coordinates. examples: - - | A RectangularAnnulus aperture with a single position, inner full width and height of 3 and 2 pixels, outer full width and height of 5 and 4 pixels, and a rotation angle of 80 degrees. - | ! h_in: 2.0 h_out: 4.0 positions: [10.0, 20.0] theta: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 80.0 w_in: 3.0 w_out: 5.0 - - | A SkyRectangularAnnulus aperture with two positions, inner full width and height of 3 and 2 arcsec, outer full width and height of 5 and 4 arcsec, and a position angle of 80 degrees. - | ! h_in: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 2.0 h_out: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 4.0 positions: ! dec: ! unit: !unit/unit-1.0.0 deg value: !core/ndarray-1.1.0 source: 1 datatype: float64 byteorder: little shape: [2] frame: icrs ra: ! unit: !unit/unit-1.0.0 deg value: !core/ndarray-1.1.0 source: 0 datatype: float64 byteorder: little shape: [2] wrap_angle: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 360.0 representation_type: spherical theta: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 80.0 w_in: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 3.0 w_out: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 5.0 type: object properties: positions: title: Aperture position(s). description: | The coordinates of the aperture center(s) in one of the following formats. If a RectangularAnnulus in pixel coordinates: * single ``(x, y)`` pair as an array * `numpy.ndarray` of ``(x, y)`` pairs If a SkyRectangularAnnulus: * `astropy.coordinates.SkyCoord` object anyOf: - type: array items: type: number maxItems: 2 minItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' w_in: title: Inner width. description: | RectangularAnnulus: the inner full width of the rectangular annulus in pixels. SkyRectangularAnnulus: the inner full width of the rectangular annulus in angular units. For ``theta=0`` the width side is along the North-South axis. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' w_out: title: Outer width. description: | RectangularAnnulus: the outer full width of the rectangular annulus in pixels. SkyRectangularAnnulus: the outer full width of the rectangular annulus in angular units. For ``theta=0`` the width side is along the North-South axis. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' h_in: title: Inner height. description: | RectangularAnnulus: the inner full height of the rectangular annulus in pixels. SkyRectangularAnnulus: the inner full height of the rectangular annulus in angular units. For ``theta=0`` the height side is along the East-West axis. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' h_out: title: Outer height. description: | RectangularAnnulus: the outer full height of the rectangular annulus in pixels. SkyRectangularAnnulus: the outer full height of the rectangular annulus in angular units. For ``theta=0`` the height side is along the East-West axis. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: title: Rotation angle. description: | RectangularAnnulus: the rotation angle of the rectangle from the positive x-axis, as an angular quantity or as a number in radians. The rotation angle increases counterclockwise. SkyRectangularAnnulus: 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). anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' required: [positions, w_in, w_out, h_out] additionalProperties: false # RectangularAnnulus and SkyRectangularAnnulus share a tag, so this # requires a file to use pixel values or angular quantities # consistently. ``theta`` is constrained only in the sky branch because # RectangularAnnulus also accepts an angular quantity. oneOf: - title: Pixel coordinates properties: positions: anyOf: - type: array - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' w_in: type: number w_out: type: number h_in: type: number h_out: type: number - title: Sky coordinates properties: positions: tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' w_in: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' w_out: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' h_in: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' h_out: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' ... astropy-photutils-24be529/photutils/resources/schemas/aperture/rectangular_aperture-1.0.0.yaml000066400000000000000000000116601526452531300326670ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/aperture/rectangular_aperture-1.0.0' title: Rectangular aperture description: | ASDF schema for serializing a rectangular aperture with one or more positions, in pixel or sky coordinates. examples: - - | A RectangularAperture with a single position, a full width of 5 pixels, a full height of 3 pixels, and a rotation angle of 80 degrees. - | ! h: 3.0 positions: [10.0, 20.0] theta: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 80.0 w: 5.0 - - | A SkyRectangularAperture with a single position, a full width of 1 arcsec, a full height of 0.5 arcsec, and a position angle of 0 degrees. - | ! h: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 0.5 positions: ! dec: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 30.0 frame: icrs ra: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 10.0 wrap_angle: ! datatype: float64 unit: !unit/unit-1.0.0 deg value: 360.0 representation_type: spherical theta: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 deg value: 0.0 w: !unit/quantity-1.3.0 datatype: float64 unit: !unit/unit-1.0.0 arcsec value: 1.0 type: object properties: positions: title: Aperture position(s). description: | The coordinates of the aperture center(s) in one of the following formats. If a RectangularAperture in pixel coordinates: * single ``(x, y)`` pair as an array * `numpy.ndarray` of ``(x, y)`` pairs If a SkyRectangularAperture: * `astropy.coordinates.SkyCoord` object anyOf: - type: array items: type: number maxItems: 2 minItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' - tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' w: title: Full width. description: | RectangularAperture: the full width of the rectangle in pixels. For ``theta=0`` the width side is along the x-axis. SkyRectangularAperture: the full width of the rectangle in angular units. For ``theta=0`` the width side is along the North-South axis. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' h: title: Full height. description: | RectangularAperture: the full height of the rectangle in pixels. For ``theta=0`` the height side is along the y-axis. SkyRectangularAperture: the full height of the rectangle in angular units. For ``theta=0`` the height side is along the East-West axis. anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: title: Rotation angle. description: | RectangularAperture: the rotation angle of the rectangle from the positive x-axis, as an angular quantity or as a number in radians. The rotation angle increases counterclockwise. SkyRectangularAperture: 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). anyOf: - type: number - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' required: [positions, w, h] additionalProperties: false # RectangularAperture and SkyRectangularAperture share a tag, so this # requires a file to use pixel values or angular quantities # consistently. ``theta`` is constrained only in the sky branch because # RectangularAperture also accepts an angular quantity. oneOf: - title: Pixel coordinates properties: positions: anyOf: - type: array - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' w: type: number h: type: number - title: Sky coordinates properties: positions: tag: 'tag:astropy.org:astropy/coordinates/skycoord-1.0.0' w: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' h: tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' theta: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - tag: 'tag:astropy.org:astropy/coordinates/angle-1.*' ... astropy-photutils-24be529/photutils/resources/schemas/psf/000077500000000000000000000000001526452531300240705ustar00rootroot00000000000000astropy-photutils-24be529/photutils/resources/schemas/psf/airy_disk_prf-1.0.0.yaml000066400000000000000000000046311526452531300302370ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/airy_disk_prf-1.0.0' title: AiryDiskPRF description: | ASDF schema for serializing an AiryDiskPRF model, a 2D Airy disk PSF integrated over pixels, defined by the total flux, center coordinates, and radius. examples: - - | An AiryDiskPRF with a flux of 71.4, centered at (24.3, 25.2) pixels, and a radius of 5 pixels: AiryDiskPRF(flux=71.4, x_0=24.3, y_0=25.2, radius=5) - | ! bbox_factor: 10.0 bounds: radius: [1.1754943508222875e-38, null] fixed: {radius: true} flux: 71.4 inputs: [x, y] n_nodes: 9 outputs: [z] radius: 5.0 x_0: 24.3 y_0: 25.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. radius: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Radius of the Airy disk at the first zero, in pixels. bbox_factor: type: number description: | The multiple of the FWHM used to define the bounding box limits. n_nodes: type: integer minimum: 1 description: | The number of Gauss-Legendre quadrature nodes along each axis of a pixel used to integrate the profile over the pixel. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, radius] ... astropy-photutils-24be529/photutils/resources/schemas/psf/airy_disk_psf-1.0.0.yaml000066400000000000000000000042221526452531300302340ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/airy_disk_psf-1.0.0' title: AiryDiskPSF description: | ASDF schema for serializing an AiryDiskPSF model, a 2D Airy disk PSF defined by the total flux, center coordinates, and radius. examples: - - | An AiryDiskPSF with a flux of 71.4, centered at (24.3, 25.2) pixels, and a radius of 5 pixels: AiryDiskPSF(flux=71.4, x_0=24.3, y_0=25.2, radius=5) - | ! bbox_factor: 10.0 bounds: radius: [1.1754943508222875e-38, null] fixed: {radius: true} flux: 71.4 inputs: [x, y] outputs: [z] radius: 5.0 x_0: 24.3 y_0: 25.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. radius: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Radius of the Airy disk at the first zero, in pixels. bbox_factor: type: number description: | The multiple of the FWHM used to define the bounding box limits. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, radius] ... astropy-photutils-24be529/photutils/resources/schemas/psf/circular_gaussian_prf-1.0.0.yaml000066400000000000000000000044051526452531300317560ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/circular_gaussian_prf-1.0.0' title: CircularGaussianPRF description: | ASDF schema for serializing a CircularGaussianPRF model, a circular 2D Gaussian PSF integrated over pixels, defined by the total flux, center coordinates, and FWHM. examples: - - | A CircularGaussianPRF with a flux of 71.4, centered at (24.3, 25.2) pixels, and a FWHM of 5 pixels: CircularGaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=5) - | ! bbox_factor: 10.0 bounds: fwhm: [1.1754943508222875e-38, null] fixed: {fwhm: true} flux: 71.4 inputs: [x, y] outputs: [z] fwhm: 5.0 x_0: 24.3 y_0: 25.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. fwhm: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Full width at half maximum of the Gaussian profile, in pixels. bbox_factor: type: number description: | The multiple of the standard deviation (sigma) used to define the bounding box limits. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, fwhm] ... astropy-photutils-24be529/photutils/resources/schemas/psf/circular_gaussian_psf-1.0.0.yaml000066400000000000000000000043551526452531300317630ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/circular_gaussian_psf-1.0.0' title: CircularGaussianPSF description: | ASDF schema for serializing a CircularGaussianPSF model, a circular 2D Gaussian PSF defined by the total flux, center coordinates, and FWHM. examples: - - | A CircularGaussianPSF with a flux of 71.4, centered at (24.3, 25.2) pixels, and a FWHM of 5 pixels: CircularGaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=5) - | ! bbox_factor: 10.0 bounds: fwhm: [1.1754943508222875e-38, null] fixed: {fwhm: true} flux: 71.4 inputs: [x, y] outputs: [z] fwhm: 5.0 x_0: 24.3 y_0: 25.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. fwhm: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Full width at half maximum of the Gaussian profile, in pixels. bbox_factor: type: number description: | The multiple of the standard deviation (sigma) used to define the bounding box limits. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, fwhm] ... astropy-photutils-24be529/photutils/resources/schemas/psf/circular_gaussian_sigma_prf-1.0.0.yaml000066400000000000000000000044701526452531300331400ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/circular_gaussian_sigma_prf-1.0.0' title: CircularGaussianSigmaPRF description: | ASDF schema for serializing a CircularGaussianSigmaPRF model, a circular 2D Gaussian PSF integrated over pixels, defined by the total flux, center coordinates, and standard deviation. examples: - - | A CircularGaussianSigmaPRF with a flux of 71.4, centered at (24.3, 25.2) pixels, and a standard deviation of 5.1 pixels: CircularGaussianSigmaPRF(flux=71.4, x_0=24.3, y_0=25.2, sigma=5.1) - | ! bbox_factor: 5.5 bounds: sigma: [1.1754943508222875e-38, null] fixed: {sigma: true} flux: 71.4 inputs: [x, y] outputs: [z] sigma: 5.1 x_0: 24.3 y_0: 25.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. sigma: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Standard deviation of the Gaussian profile, in pixels. bbox_factor: type: number description: | The multiple of the standard deviation (sigma) used to define the bounding box limits. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, sigma] ... astropy-photutils-24be529/photutils/resources/schemas/psf/gaussian_prf-1.0.0.yaml000066400000000000000000000060541526452531300300740ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/gaussian_prf-1.0.0' title: GaussianPRF description: | ASDF schema for serializing a GaussianPRF model, a 2D Gaussian PSF integrated over pixels, defined by the total flux, center coordinates, FWHMs, and rotation angle. examples: - - | A GaussianPRF with a flux of 71.4, centered at (24.3, 25.2) pixels, with FWHMs of 5 pixels along the x-axis and 6 pixels along the y-axis, and a rotation angle of 21.7 degrees: GaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=5, y_fwhm=6, theta=21.7) - | ! bbox_factor: 5.5 bounds: x_fwhm: [1.1754943508222875e-38, null] y_fwhm: [1.1754943508222875e-38, null] fixed: {theta: true, x_fwhm: true, y_fwhm: true} flux: 71.4 inputs: [x, y] outputs: [z] theta: 21.7 x_0: 24.3 x_fwhm: 5.0 y_0: 25.2 y_fwhm: 6.0 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. x_fwhm: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Full width at half maximum of the Gaussian profile along the x-axis, in pixels. y_fwhm: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Full width at half maximum of the Gaussian profile along the y-axis, in pixels. theta: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Rotation angle of the Gaussian profile, either as a number in degrees or as an angular quantity. The angle is measured counterclockwise from the positive x-axis. bbox_factor: type: number description: | The multiple of the x and y standard deviations (sigma) used to define the bounding box limits. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, x_fwhm, y_fwhm] ... astropy-photutils-24be529/photutils/resources/schemas/psf/gaussian_psf-1.0.0.yaml000066400000000000000000000060241526452531300300720ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/gaussian_psf-1.0.0' title: GaussianPSF description: | ASDF schema for serializing a GaussianPSF model, a 2D Gaussian PSF defined by the total flux, center coordinates, FWHMs, and rotation angle. examples: - - | A GaussianPSF with a flux of 71.4, centered at (24.3, 25.2) pixels, with FWHMs of 5 pixels along the x-axis and 6 pixels along the y-axis, and a rotation angle of 21.7 degrees: GaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=5, y_fwhm=6, theta=21.7) - | ! bbox_factor: 5.5 bounds: x_fwhm: [1.1754943508222875e-38, null] y_fwhm: [1.1754943508222875e-38, null] fixed: {theta: true, x_fwhm: true, y_fwhm: true} flux: 71.4 inputs: [x, y] outputs: [z] theta: 21.7 x_0: 24.3 x_fwhm: 5.0 y_0: 25.2 y_fwhm: 6.0 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. x_fwhm: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Full width at half maximum of the Gaussian profile along the x-axis, in pixels. y_fwhm: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Full width at half maximum of the Gaussian profile along the y-axis, in pixels. theta: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Rotation angle of the Gaussian profile, either as a number in degrees or as an angular quantity. The angle is measured counterclockwise from the positive x-axis. bbox_factor: type: number description: | The multiple of the x and y standard deviations (sigma) used to define the bounding box limits. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, x_fwhm, y_fwhm] ... astropy-photutils-24be529/photutils/resources/schemas/psf/gridded_psf_model-1.0.0.yaml000066400000000000000000000070311526452531300310410ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/gridded_psf_model-1.0.0' title: GriddedPSFModel description: | ASDF schema for serializing a GriddedPSFModel, a grid of 2D ePSF models defined at fiducial detector locations that form a rectangular grid. examples: - - | A GriddedPSFModel with four 6x6 ePSF images on a 2x2 grid, a flux of 6, and an oversampling factor of 1 along both axes. - | ! data: !core/ndarray-1.1.0 source: 0 datatype: int64 byteorder: little shape: [4, 6, 6] fill_value: .nan flux: 6.0 grid_xypos: !core/ndarray-1.1.0 source: 2 datatype: int64 byteorder: little shape: [4, 2] inputs: [x, y] meta: detector: NRCA1 filter: F150W instrument: NIRCam outputs: [z] oversampling: !core/ndarray-1.1.0 source: 1 datatype: int64 byteorder: little shape: [2] x_0: 0.5 y_0: 0.5 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: data: tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | A 3D array of shape (N, My, Mx) containing the N ePSF images of shape (My, Mx). flux: type: number description: | The flux scaling factor, which corresponds to the total source flux assuming that the input image is properly normalized. x_0: type: number description: | The x coordinate of the ePSF peak in the output coordinate grid where the model is evaluated. y_0: type: number description: | The y coordinate of the ePSF peak in the output coordinate grid where the model is evaluated. fill_value: type: number description: | Assign value to pixels outside the input pixel grid to avoid extrapolation. oversampling: anyOf: - type: array items: type: integer minItems: 2 maxItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | The integer oversampling factor of the input ePSF images along each axis, in numpy ``(y, x)`` order. grid_xypos: tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | The ``(x, y)`` grid position of each reference ePSF, in the same order as the first axis of ``data``; that is, ``grid_xypos[i]`` is the position of the ePSF in ``data[i]``. The grid positions must form a rectangular grid. meta: type: object description: | Additional metadata describing the ePSF grid, such as the telescope, instrument, detector, or filter. The ``grid_xypos`` and ``oversampling`` items are stored as separate properties, so neither appears here. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [data, oversampling, grid_xypos, meta] ... astropy-photutils-24be529/photutils/resources/schemas/psf/image_psf-1.0.0.yaml000066400000000000000000000063131526452531300273430ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/image_psf-1.0.0' title: ImagePSF description: | ASDF schema for serializing an ImagePSF model, a 2D image PSF that is evaluated at arbitrary positions by spline interpolation. examples: - - | An ImagePSF with a flux of 71.4, centered at (24.3, 25.2) pixels, and an image of shape (11, 11): ImagePSF(data=np.ones((11, 11)), flux=71.4, x_0=24.3, y_0=25.2) - | ! data: !core/ndarray-1.1.0 source: 0 datatype: float64 byteorder: little shape: [11, 11] fill_value: 0.0 flux: 71.4 inputs: [x, y] origin: !core/ndarray-1.1.0 source: 2 datatype: float64 byteorder: little shape: [2] offset: 8 strides: [-8] outputs: [z] oversampling: !core/ndarray-1.1.0 source: 1 datatype: int64 byteorder: little shape: [2] x_0: 24.3 y_0: 25.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: data: tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | A 2D array containing the PSF image. flux: type: number description: | The flux scaling factor, which corresponds to the total source flux assuming that the input image is properly normalized. x_0: type: number description: | The x position of a feature in the image, typically the PSF peak, in the output coordinate grid where the model is evaluated. y_0: type: number description: | The y position of a feature in the image, typically the PSF peak, in the output coordinate grid where the model is evaluated. origin: anyOf: - type: array items: type: number minItems: 2 maxItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | The ``(x, y)`` coordinate with respect to the input image data array that represents the reference pixel of the input data. fill_value: type: number description: | Assign value to pixels outside the input pixel grid to avoid extrapolation. oversampling: anyOf: - type: array items: type: integer minItems: 2 maxItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | The integer oversampling factor of the input PSF image along each axis, in numpy ``(y, x)`` order. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [data] ... astropy-photutils-24be529/photutils/resources/schemas/psf/moffat_prf-1.0.0.yaml000066400000000000000000000051211526452531300275300ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/moffat_prf-1.0.0' title: MoffatPRF description: | ASDF schema for serializing a MoffatPRF model, a 2D Moffat PSF integrated over pixels, defined by the total flux, center coordinates, and the alpha and beta parameters. examples: - - | A MoffatPRF with a flux of 71.4, centered at (24.3, 25.2) pixels, with an alpha of 5.1 and a beta of 3.2: MoffatPRF(flux=71.4, x_0=24.3, y_0=25.2, alpha=5.1, beta=3.2) - | ! bbox_factor: 10.0 bounds: alpha: [1.1754943508222875e-38, null] beta: [1.0, null] fixed: {alpha: true, beta: true} flux: 71.4 inputs: [x, y] n_nodes: 9 outputs: [z] x_0: 24.3 y_0: 25.2 alpha: 5.1 beta: 3.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. alpha: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Characteristic radius of the Moffat profile. beta: type: number description: | Power-law index of the Moffat profile. bbox_factor: type: number description: | The multiple of the FWHM used to define the bounding box limits. n_nodes: type: integer minimum: 1 description: | The number of Gauss-Legendre quadrature nodes along each axis of a pixel used to integrate the profile over the pixel. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, alpha, beta] ... astropy-photutils-24be529/photutils/resources/schemas/psf/moffat_psf-1.0.0.yaml000066400000000000000000000045141526452531300275360ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/moffat_psf-1.0.0' title: MoffatPSF description: | ASDF schema for serializing a MoffatPSF model, a 2D Moffat PSF defined by the total flux, center coordinates, and the alpha and beta parameters. examples: - - | A MoffatPSF with a flux of 71.4, centered at (24.3, 25.2) pixels, with an alpha of 5.1 and a beta of 3.2: MoffatPSF(flux=71.4, x_0=24.3, y_0=25.2, alpha=5.1, beta=3.2) - | ! bbox_factor: 10.0 bounds: alpha: [1.1754943508222875e-38, null] beta: [1.0, null] fixed: {alpha: true, beta: true} flux: 71.4 inputs: [x, y] outputs: [z] x_0: 24.3 y_0: 25.2 alpha: 5.1 beta: 3.2 allOf: - $ref: 'http://stsci.edu/schemas/asdf/transform/transform-1.4.0' - type: object properties: flux: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Total integrated flux of the PSF. x_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the x-axis. y_0: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Position of the peak along the y-axis. alpha: anyOf: - tag: 'tag:stsci.edu:asdf/unit/quantity-1.*' - type: number description: | Characteristic radius of the Moffat profile. beta: type: number description: | Power-law index of the Moffat profile. bbox_factor: type: number description: | The multiple of the FWHM used to define the bounding box limits. # The properties of the referenced transform schema are repeated # here because "additionalProperties" applies only to the # properties defined in the same subschema. bounding_box: {} bounds: {} fixed: {} input_units_equivalencies: {} inputs: {} inverse: {} name: {} outputs: {} additionalProperties: false required: [flux, x_0, y_0, alpha, beta] ... astropy-photutils-24be529/photutils/resources/schemas/psf/stdpsf_grid-1.0.0.yaml000066400000000000000000000047041526452531300277230ustar00rootroot00000000000000%YAML 1.1 --- $schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' id: 'asdf://astropy.org/photutils/schemas/psf/stdpsf_grid-1.0.0' title: STDPSFGrid description: | ASDF schema for serializing a STDPSFGrid, a grid of 2D ePSF models read from a file in the STDPSF format. examples: - - | A STDPSFGrid with nine 101x101 ePSF images on a 3x3 grid, read from a WFC3/IR F153M STDPSF file. - | ! data: !core/ndarray-1.1.0 source: 0 datatype: float32 byteorder: big shape: [9, 101, 101] grid_shape: [3, 3] grid_xypos: !core/ndarray-1.1.0 source: 1 datatype: int64 byteorder: little shape: [9, 2] meta: STDPSF: STDPSF_WFC3IR_F153M.fits detector: WFC3IR filter: F153M oversampling: [4, 4] type: object properties: data: tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | A 3D array of shape (N, My, Mx) containing the N ePSF images of shape (My, Mx). grid_xypos: tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | The ``(x, y)`` fiducial detector position of each ePSF, in the same order as the first axis of ``data``. grid_shape: type: array description: | The shape of the ePSF grid in numpy ``(ny, nx)`` order. items: type: integer minItems: 2 maxItems: 2 oversampling: anyOf: - type: array items: type: integer minItems: 2 maxItems: 2 - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' description: | The integer oversampling factor of the ePSF images along each axis, in numpy ``(y, x)`` order. meta: type: object description: | Additional metadata describing the ePSF grid, such as the STDPSF file it was read from, the detector, or the filter. The ``grid_xypos``, ``grid_shape``, and ``oversampling`` items are stored as separate properties, so none of them appear here. properties: STDPSF: type: string description: | The name of the STDPSF file that the ePSFs were read from. detector: type: string description: | The name of the detector. filter: type: string description: | The name of the filter. required: [data, grid_xypos, grid_shape, meta] additionalProperties: false ... astropy-photutils-24be529/photutils/segmentation/000077500000000000000000000000001526452531300223405ustar00rootroot00000000000000astropy-photutils-24be529/photutils/segmentation/__init__.py000066400000000000000000000010011526452531300244410ustar00rootroot00000000000000# 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 .flags import * # noqa: F401, F403 from .utils import * # noqa: F401, F403 astropy-photutils-24be529/photutils/segmentation/_batch_catalog.pyx000066400000000000000000003306001526452531300260160ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Batch Cython drivers for `~photutils.segmentation.SourceCatalog` per-source computations. Each driver accumulates per-pixel contributions directly into per-source outputs without generating per-source cutouts or making per-source Python calls. The per-pixel segmentation masking (mask or mirror-correct) uses the same helper as the aperture batch drivers, so the two packages share the same masking semantics. The moment kernels accumulate the raw and central spatial moments (and the raw centroid error sums) over each source's segment bounding box, using cutout-frame coordinates, so that the moment-based properties never need per-source cutout arrays. The segment gather packs the unmasked segment pixel values of all sources into a single array for the pixel-statistics properties (e.g., ``segment_flux``). The fractional-flux radius solver additionally runs its bracketed root-find entirely in C, using the ``brentq`` implementation exported by ``scipy.optimize.cython_optimize``, so that no Python call is made per root-finder iteration. Its prepared per-source inputs (the cleaned cutouts) are packed into one buffer, so the preparation and the solve run their per-source computations without the GIL. The source loops run without the GIL and use no global mutable state, so this module is safe to use from multiple threads, including on free-threaded Python builds. The image arrays are declared with fused types, so the drivers read C-contiguous float32 or float64 images and int32 or intp segmentation images directly, without the caller making full-image copies. All of the floating-point images passed in one call must have the same dtype. Each pixel value is converted to double as it is read, which is exact, and all arithmetic is performed in double precision, so the results agree to within rounding for either input dtype. """ import numpy as np from scipy.optimize.cython_optimize cimport brentq, zeros_full_output from photutils.segmentation._batch_results import BatchFluxRadiusArgs from photutils.aperture._batch_overlap cimport (_circle_pixel_frac, _seg_pixel_contributes, real_t, seg_t) __all__ = ['batch_central_moments', 'batch_centroid_win', 'batch_flux_radius_prepare', 'batch_flux_radius_solve', 'batch_kron_radius', 'batch_local_background', 'batch_minmax_index', 'batch_moment_err', 'batch_perimeter', 'batch_quad_boxes', 'batch_raw_moments', 'batch_segment_gather'] cdef extern from "math.h" nogil: double exp(double x) double sqrt(double x) double sin(double x) double cos(double x) double floor(double x) double ceil(double x) double fmax(double x, double y) double fmin(double x, double y) double fabs(double x) bint isfinite(double x) double NAN double INFINITY cdef int _check_length(Py_ssize_t n, Py_ssize_t n_src, str name) except -1: """ Validate the length of a per-source input array. This is kept out of the drivers so that their error handling stays out of the per-pixel loops. The drivers compile with ``boundscheck=False``, so a short per-source array would otherwise read out of bounds. Parameters ---------- n : Py_ssize_t The length of the per-source array. n_src : Py_ssize_t The number of sources (the length of ``labels``). name : str The name of the per-source array, used in the error message. Returns ------- result : int Zero. A `ValueError` is raised if the length is invalid. """ if n != n_src: msg = f'{name} must have the same length as labels' raise ValueError(msg) return 0 cdef int _check_shape(Py_ssize_t ny, Py_ssize_t nx, Py_ssize_t ny_ref, Py_ssize_t nx_ref, str name, str ref_name) except -1: """ Validate the shape of a 2D image-sized input array. This is kept out of the drivers so that their error handling stays out of the per-pixel loops. The drivers compile with ``boundscheck=False``, so a smaller array would otherwise read out of bounds. Parameters ---------- ny, nx : Py_ssize_t The shape of the array being checked. ny_ref, nx_ref : Py_ssize_t The shape of the reference array. name : str The name of the array being checked, used in the error message. ref_name : str The name of the reference array, used in the error message. Returns ------- result : int Zero. A `ValueError` is raised if the shape is invalid. """ if ny != ny_ref or nx != nx_ref: msg = f'{name} must have the same shape as {ref_name}' raise ValueError(msg) return 0 cdef void _centroid_win_source(const real_t *data, const real_t *error, const unsigned char *mask, const seg_t *segm, Py_ssize_t nx_data, Py_ssize_t ny_data, Py_ssize_t label, double xcen0, double ycen0, double sigma, int seg_method, bint compute_err, Py_ssize_t max_aper_size, double *out) noexcept nogil: """ Compute the raw windowed-centroid quantities for a single source. The centroid is an iterative Gaussian-weighted centroid within a binary circular window of radius ``4 * sigma``, with masked pixels contributing zero and neighbor-source pixels excluded or mirror-corrected per ``seg_method``. Writes the 10 output columns ``(xcen, ycen, weighted_flux, cen_mom_xx, cen_mom_yy, cen_mom_xy, err_sum, err_var_x, err_var_y, err_cov_xy)`` into ``out``. """ cdef double inv_2sigma2 = -1.0 / (2.0 * sigma * sigma) cdef double radius = 4.0 * sigma cdef double radius_sq = radius * radius # The half size truncates (radius + 1.5) toward zero like int() cdef Py_ssize_t bbox_halfsize = (radius + 1.5) cdef Py_ssize_t full_n = 2 * bbox_halfsize + 1 out[0] = NAN out[1] = NAN out[2] = 0.0 out[3] = 0.0 out[4] = 0.0 out[5] = 0.0 out[6] = NAN out[7] = NAN out[8] = NAN out[9] = NAN if full_n * full_n > max_aper_size: return cdef double xcen = xcen0 cdef double ycen = ycen0 cdef double dcen = 1.0 cdef double weighted_flux = 0.0 cdef double dx_mom = 0.0 cdef double dy_mom = 0.0 cdef Py_ssize_t iter_ = 0 cdef Py_ssize_t ixmin, ixmax, iymin, iymax, x0, x1, y0, y1 cdef Py_ssize_t ccx, ccy, ix, iy, six, siy cdef double dx, dy, rr2, w, v, e, wv, wsq cdef double sumw, sumwx, sumwy # State of the last completed accumulation, for the final # moment/error pass (the window may go empty on a later iteration, # in which case the last nonempty window is reused) cdef bint have_window = False cdef double xc_last = 0.0, yc_last = 0.0 cdef double dxm_last = 0.0, dym_last = 0.0 cdef Py_ssize_t lx0 = 0, lx1 = 0, ly0 = 0, ly1 = 0 cdef Py_ssize_t lccx = 0, lccy = 0 cdef double sxx, syy, sxy, esum, evx, evy, ecxy while iter_ < 16 and dcen > 0.0001: # The window origin truncates (xcen + 0.5) toward zero, as # Python's int() does, including for negative centers ixmin = (xcen + 0.5) - bbox_halfsize ixmax = ixmin + full_n iymin = (ycen + 0.5) - bbox_halfsize iymax = iymin + full_n x0 = ixmin if ixmin > 0 else 0 x1 = ixmax if ixmax < nx_data else nx_data y0 = iymin if iymin > 0 else 0 y1 = iymax if iymax < ny_data else ny_data if y0 >= y1 or x0 >= x1: xcen = NAN ycen = NAN break ccx = (xcen + 0.5) ccy = (ycen + 0.5) sumw = 0.0 sumwx = 0.0 sumwy = 0.0 for iy in range(y0, y1): for ix in range(x0, x1): dx = ix - xcen dy = iy - ycen rr2 = dx * dx + dy * dy if rr2 > radius_sq: continue if mask[iy * nx_data + ix] != 0: continue six = ix siy = iy if (seg_method != 0 and not _seg_pixel_contributes( segm, mask, nx_data, seg_method, label, ix, iy, x0, x1, y0, y1, ccx, ccy, &six, &siy)): continue v = data[siy * nx_data + six] w = exp(rr2 * inv_2sigma2) wv = v * w sumw += wv sumwx += wv * dx sumwy += wv * dy weighted_flux = sumw # 0/0 yields NaN (cdivision), matching the suppressed NumPy # RuntimeWarning path. A NaN dcen ends the loop dx_mom = sumwx / sumw dy_mom = sumwy / sumw dcen = sqrt(dx_mom * dx_mom + dy_mom * dy_mom) have_window = True xc_last = xcen yc_last = ycen dxm_last = dx_mom dym_last = dy_mom lx0 = x0 lx1 = x1 ly0 = y0 ly1 = y1 lccx = ccx lccy = ccy xcen += dx_mom * 2.0 ycen += dy_mom * 2.0 iter_ += 1 out[0] = xcen out[1] = ycen out[2] = weighted_flux if not (isfinite(weighted_flux) and weighted_flux > 0 and have_window): return # Final pass over the last completed window using the pre-update # center, computing the windowed central 2nd-order moments and # raw error sums. sxx = 0.0 syy = 0.0 sxy = 0.0 esum = 0.0 evx = 0.0 evy = 0.0 ecxy = 0.0 for iy in range(ly0, ly1): for ix in range(lx0, lx1): dx = ix - xc_last dy = iy - yc_last rr2 = dx * dx + dy * dy if rr2 > radius_sq: continue if mask[iy * nx_data + ix] != 0: continue six = ix siy = iy if (seg_method != 0 and not _seg_pixel_contributes( segm, mask, nx_data, seg_method, label, ix, iy, lx0, lx1, ly0, ly1, lccx, lccy, &six, &siy)): continue v = data[siy * nx_data + six] w = exp(rr2 * inv_2sigma2) wv = v * w sxx += wv * dx * dx syy += wv * dy * dy sxy += wv * dx * dy if compute_err: e = error[siy * nx_data + six] wsq = w * w esum += wsq * e * e evx += wsq * e * e * dx * dx evy += wsq * e * e * dy * dy ecxy += wsq * e * e * dx * dy out[3] = sxx / weighted_flux - dxm_last * dxm_last out[4] = syy / weighted_flux - dym_last * dym_last out[5] = sxy / weighted_flux - dxm_last * dym_last if compute_err: out[6] = esum out[7] = evx out[8] = evy out[9] = ecxy def batch_centroid_win(const real_t[:, ::1] data, *, const real_t[:, ::1] error, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const double[::1] xcen0, const double[::1] ycen0, const double[::1] sigma, const unsigned char[::1] skip, int seg_method, int compute_err, Py_ssize_t max_aper_size): """ Compute windowed-centroid quantities for many sources in one call. For each source, an iterative Gaussian-weighted centroid is computed within a binary circular window of radius ``4 * sigma`` centered on the current centroid estimate. The final windowed second-order moments and raw error sums are computed over the last nonempty window. The pixel sums are accumulated sequentially rather than pairwise. Parameters ---------- data : 2D ndarray of float32 or float64 (C-contiguous) The data array. error : 2D ndarray of float32 or float64 (C-contiguous) or `None` The pixel-wise 1-sigma errors. Must have the same shape as ``data``. Required (not `None`) if ``compute_err`` is nonzero. mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked (excluded) pixels. Must have the same shape as ``data``. Bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. Any nonzero value excludes the pixel and prevents it from being used as a mirror pixel. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``data``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. xcen0, ycen0 : 1D ndarray of float64 (C-contiguous) The initial (isophotal) centroids for each source, with shape ``(n_sources,)``. sigma : 1D ndarray of float64 (C-contiguous) The Gaussian weighting sigma for each source, with shape ``(n_sources,)``. skip : 1D ndarray of uint8 (C-contiguous) Nonzero for sources that cannot have a meaningful windowed centroid (e.g., a nonfinite half-light radius or a nonfinite initial centroid), with shape ``(n_sources,)``. seg_method : int The segmentation masking method: * 0: disables masking * 1: excludes neighbor-source pixels (``(seg > 0) & (seg != label)``) * 4: replaces neighbor-source pixels with the values mirrored across the (rounded) window center (the symmetric ``'correct'`` method). A neighbor pixel whose mirror falls outside the clipped window, is itself a neighbor, or is masked is excluded instead of replaced. compute_err : int If nonzero, also accumulate the raw (unnormalized) weighted error sums. max_aper_size : Py_ssize_t The maximum number of pixels in the (unclipped) window. Sources with a larger window are skipped (out-of-memory guard). Returns ------- result : 2D ndarray of float64 The raw per-source results with shape ``(n_sources, 10)`` and columns ``(xcen, ycen, weighted_flux, cen_mom_xx, cen_mom_yy, cen_mom_xy, err_sum, err_var_x, err_var_y, err_cov_xy)``. The error columns are NaN unless ``compute_err`` is nonzero. Skipped sources (including those rejected by ``max_aper_size``) get the row ``(nan, nan, 0, 0, 0, 0, nan, nan, nan, nan)``. Raises ------ ValueError If ``compute_err`` is nonzero and ``error`` is `None`, if a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``data``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = data.shape[0] cdef Py_ssize_t nx_data = data.shape[1] cdef bint has_error = error is not None if compute_err and not has_error: msg = 'error must be provided when compute_err is set' raise ValueError(msg) _check_length(xcen0.shape[0], n_src, 'xcen0') _check_length(ycen0.shape[0], n_src, 'ycen0') _check_length(sigma.shape[0], n_src, 'sigma') _check_length(skip.shape[0], n_src, 'skip') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'data') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'data') if has_error: _check_shape(error.shape[0], error.shape[1], ny_data, nx_data, 'error', 'data') results_arr = np.empty((n_src, 10)) cdef double[:, ::1] results = results_arr cdef const real_t *err_ptr = NULL if has_error: err_ptr = &error[0, 0] cdef Py_ssize_t i with nogil: for i in range(n_src): if skip[i]: results[i, 0] = NAN results[i, 1] = NAN results[i, 2] = 0.0 results[i, 3] = 0.0 results[i, 4] = 0.0 results[i, 5] = 0.0 results[i, 6] = NAN results[i, 7] = NAN results[i, 8] = NAN results[i, 9] = NAN continue _centroid_win_source(&data[0, 0], err_ptr, &mask[0, 0], &segm[0, 0], nx_data, ny_data, labels[i], xcen0[i], ycen0[i], sigma[i], seg_method, compute_err, max_aper_size, &results[i, 0]) return results_arr cdef void _kron_radius_source(const real_t *data, const unsigned char *mask, const seg_t *segm, Py_ssize_t nx_data, Py_ssize_t ny_data, Py_ssize_t label, double xc, double yc, double a, double b, double theta, double cxx, double cxy, double cyy, int seg_method, double scale, double min_circ_radius, Py_ssize_t max_aper_size, double *out) noexcept nogil: """ Accumulate the Kron radius numerator and denominator for a single source. The numerator and denominator are the sums of ``data * r`` and ``data`` over the pixels whose centers fall inside the ellipse of elliptical radius ``scale`` (or the circle of radius ``min_circ_radius`` when both axes are zero), excluding masked pixels and handling neighbor-source pixels per ``seg_method``. Writes ``(numerator, denominator)`` into ``out``, or NaN for an undefined measurement (no minimum circular radius for a degenerate ellipse, an unreasonably large bounding box, or no overlap with the data). """ out[0] = NAN out[1] = NAN cdef bint use_circular = (a == 0 and b == 0) cdef double half_w, half_h, cos_theta, sin_theta if use_circular: if min_circ_radius <= 0: return half_w = min_circ_radius half_h = min_circ_radius else: cos_theta = cos(theta) sin_theta = sin(theta) half_w = sqrt(a * a * cos_theta * cos_theta + b * b * sin_theta * sin_theta) half_h = sqrt(a * a * sin_theta * sin_theta + b * b * cos_theta * cos_theta) # The bounding box is kept as integral doubles until it is # clipped, to avoid integer overflow for apertures far outside # the image cdef double ixmin_d = floor(xc - half_w + 0.5) cdef double ixmax_d = floor(xc + half_w + 0.5) + 1.0 cdef double iymin_d = floor(yc - half_h + 0.5) cdef double iymax_d = floor(yc + half_h + 0.5) + 1.0 if (ixmax_d - ixmin_d) * (iymax_d - iymin_d) > max_aper_size: return # Clip to the data cdef double x0_d = ixmin_d if ixmin_d > 0.0 else 0.0 cdef double x1_d = ixmax_d if ixmax_d < nx_data else nx_data cdef double y0_d = iymin_d if iymin_d > 0.0 else 0.0 cdef double y1_d = iymax_d if iymax_d < ny_data else ny_data if x0_d >= x1_d or y0_d >= y1_d: return cdef Py_ssize_t x0 = x0_d cdef Py_ssize_t x1 = x1_d cdef Py_ssize_t y0 = y0_d cdef Py_ssize_t y1 = y1_d # The cutout-frame center offsets and the mirror center of the # 'correct' method (the cutout center + 0.5, truncated toward # zero as Python's int() does) cdef double xoff = xc - x0 cdef double yoff = yc - y0 cdef Py_ssize_t ccx = (xoff + 0.5) + x0 cdef Py_ssize_t ccy = (yoff + 0.5) + y0 cdef double r_circ2 = min_circ_radius * min_circ_radius cdef double numerator = 0.0 cdef double denominator = 0.0 cdef Py_ssize_t ix, iy, six, siy cdef double xx, yy, rr_sq, rr, v for iy in range(y0, y1): yy = (iy - y0) - yoff for ix in range(x0, x1): xx = (ix - x0) - xoff rr_sq = cxx * xx * xx + cxy * xx * yy + cyy * yy * yy rr = sqrt(fmax(rr_sq, 0.0)) if use_circular: if xx * xx + yy * yy > r_circ2: continue elif rr > scale: continue if mask[iy * nx_data + ix] != 0: continue six = ix siy = iy if (seg_method != 0 and not _seg_pixel_contributes( segm, mask, nx_data, seg_method, label, ix, iy, x0, x1, y0, y1, ccx, ccy, &six, &siy)): continue v = data[siy * nx_data + six] numerator += v * rr denominator += v out[0] = numerator out[1] = denominator def batch_kron_radius(const real_t[:, ::1] data, *, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const double[::1] xcen, const double[::1] ycen, const double[::1] semimajor, const double[::1] semiminor, const double[::1] theta, const double[::1] cxx, const double[::1] cxy, const double[::1] cyy, const unsigned char[::1] skip, int seg_method, double scale, double min_circ_radius, Py_ssize_t max_aper_size): """ Compute the Kron radius numerator and denominator for many sources in one call. For each source, the sums of ``data * r`` and ``data`` are accumulated over the pixels whose centers fall inside the ellipse of elliptical radius ``scale`` (in units of the isophotal ellipse), where ``r`` is the elliptical radius of each pixel, or inside the circle of radius ``min_circ_radius`` for a source whose elliptical axes are both zero. The sums are accumulated sequentially rather than pairwise. The caller forms the Kron radius from the two sums. Parameters ---------- data : 2D ndarray of float32 or float64 (C-contiguous) The data array. mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked (excluded) pixels. Must have the same shape as ``data``. Bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. Any nonzero value excludes the pixel and prevents it from being used as a mirror pixel. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``data``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. xcen, ycen : 1D ndarray of float64 (C-contiguous) The source centroids, with shape ``(n_sources,)``. semimajor, semiminor : 1D ndarray of float64 (C-contiguous) The isophotal semimajor and semiminor axes (1-sigma), with shape ``(n_sources,)``. They are multiplied by ``scale`` to form the measurement ellipse. theta : 1D ndarray of float64 (C-contiguous) The ellipse orientation in radians, with shape ``(n_sources,)``. cxx, cxy, cyy : 1D ndarray of float64 (C-contiguous) The isophotal ellipse coefficients, with shape ``(n_sources,)``. skip : 1D ndarray of uint8 (C-contiguous) Nonzero for sources that cannot have a measured Kron radius (e.g., a completely masked source or a nonfinite centroid or shape), with shape ``(n_sources,)``. seg_method : int The segmentation masking method: * 0: disables masking * 1: excludes neighbor-source pixels (``(seg > 0) & (seg != label)``) * 4: replaces neighbor-source pixels with the values mirrored across the (rounded) aperture center (the symmetric ``'correct'`` method). A neighbor pixel whose mirror falls outside the clipped bounding box, is itself a neighbor, or is masked contributes zero instead. scale : float The elliptical radius of the measurement ellipse, in units of the isophotal ellipse. min_circ_radius : float The radius of the measurement circle for sources whose elliptical axes are both zero. Such sources are undefined if it is not positive. max_aper_size : Py_ssize_t The maximum number of pixels in the (unclipped) bounding box. Sources with a larger bounding box are undefined (out-of-memory guard). Returns ------- result : 2D ndarray of float64 The per-source sums with shape ``(n_sources, 2)`` and columns ``(numerator, denominator)``. Skipped and undefined sources (including those rejected by ``max_aper_size`` and those whose bounding box does not overlap the data) get the row ``(nan, nan)``. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``data``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = data.shape[0] cdef Py_ssize_t nx_data = data.shape[1] _check_length(xcen.shape[0], n_src, 'xcen') _check_length(ycen.shape[0], n_src, 'ycen') _check_length(semimajor.shape[0], n_src, 'semimajor') _check_length(semiminor.shape[0], n_src, 'semiminor') _check_length(theta.shape[0], n_src, 'theta') _check_length(cxx.shape[0], n_src, 'cxx') _check_length(cxy.shape[0], n_src, 'cxy') _check_length(cyy.shape[0], n_src, 'cyy') _check_length(skip.shape[0], n_src, 'skip') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'data') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'data') results_arr = np.empty((n_src, 2)) cdef double[:, ::1] results = results_arr cdef Py_ssize_t i with nogil: for i in range(n_src): if skip[i]: results[i, 0] = NAN results[i, 1] = NAN continue _kron_radius_source(&data[0, 0], &mask[0, 0], &segm[0, 0], nx_data, ny_data, labels[i], xcen[i], ycen[i], semimajor[i] * scale, semiminor[i] * scale, theta[i], cxx[i], cxy[i], cyy[i], seg_method, scale, min_circ_radius, max_aper_size, &results[i, 0]) return results_arr cdef void _flux_radius_cutout(const real_t *data, const unsigned char *mask, const seg_t *segm, Py_ssize_t nx_data, Py_ssize_t label, Py_ssize_t x0, Py_ssize_t x1, Py_ssize_t y0, Py_ssize_t y1, Py_ssize_t ccx, Py_ssize_t ccy, double local_bkg, int seg_method, double *out) noexcept nogil: """ Fill the cleaned, background-subtracted cutout of a single source for the flux-radius root-find. Pixels with a nonzero ``mask`` value are zero, neighbor-source pixels are zeroed or mirror-corrected per ``seg_method`` (an uncorrectable neighbor pixel is zero), and every other pixel is ``data - local_bkg``. Nonfinite pixels are zero only where the caller has folded them into ``mask``. Writes the ``(y1 - y0, x1 - x0)`` cutout in row-major order into ``out``. """ cdef Py_ssize_t ix, iy, six, siy cdef Py_ssize_t nx = x1 - x0 cdef double value for iy in range(y0, y1): for ix in range(x0, x1): value = 0.0 if mask[iy * nx_data + ix] == 0: six = ix siy = iy if (seg_method == 0 or _seg_pixel_contributes( segm, mask, nx_data, seg_method, label, ix, iy, x0, x1, y0, y1, ccx, ccy, &six, &siy)): value = data[siy * nx_data + six] - local_bkg out[(iy - y0) * nx + (ix - x0)] = value def batch_flux_radius_prepare(const real_t[:, ::1] data, *, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const double[::1] xcen, const double[::1] ycen, const double[::1] local_bkg, const double[::1] kronflux, const double[::1] max_radius, const unsigned char[::1] skip, int seg_method, int use_exact, int subpixels, Py_ssize_t max_aper_size): """ Prepare the per-source inputs of the flux-radius root-find for many sources in one call. For each source, the cutout of the data within the bounding box of the circle of radius ``max_radius`` (clipped to the data) is cleaned and background-subtracted (see ``_flux_radius_cutout``) and the `~photutils.geometry.circular_overlap_grid` grid parameters of that cutout, relative to the source centroid, are computed. The cutouts of all sources are packed into one buffer, and the source loops run without the GIL. Parameters ---------- data : 2D ndarray of float32 or float64 (C-contiguous) The data array. mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked (zeroed) pixels. Must have the same shape as ``data``. Bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. Any nonzero value zeroes the pixel and prevents it from being used as a mirror pixel. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``data``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. xcen, ycen : 1D ndarray of float64 (C-contiguous) The source centroids, with shape ``(n_sources,)``. local_bkg : 1D ndarray of float64 (C-contiguous) The per-source local background to subtract from each pixel value, with shape ``(n_sources,)``. kronflux : 1D ndarray of float64 (C-contiguous) The Kron flux of each source, with shape ``(n_sources,)``. max_radius : 1D ndarray of float64 (C-contiguous) The maximum circular radius of each source (the initial upper bracket of the root-find), with shape ``(n_sources,)``. skip : 1D ndarray of uint8 (C-contiguous) Nonzero for sources that cannot have a solution (e.g., a nonfinite centroid, Kron flux, or maximum radius, or a zero Kron flux), with shape ``(n_sources,)``. seg_method : int The segmentation masking method: * 0: disables masking * 1: zeroes neighbor-source pixels (``(seg > 0) & (seg != label)``) * 4: replaces neighbor-source pixels with the values mirrored across the (rounded) cutout center (the symmetric ``'correct'`` method). A neighbor pixel whose mirror falls outside the clipped cutout, is itself a neighbor, or is masked is zeroed instead. use_exact : int Whether the root-find computes exact overlap fractions (1) or uses subpixel sampling (0). subpixels : int The number of subpixels in each dimension when ``use_exact`` is 0. max_aper_size : Py_ssize_t The maximum number of pixels in the (unclipped) bounding box. Sources with a larger bounding box are skipped (out-of-memory guard). Returns ------- args : `~photutils.segmentation._batch_results.BatchFluxRadiusArgs` The prepared per-source inputs consumed by ``batch_flux_radius_solve``. The cleaned cutouts are packed into the ``values`` buffer, located by the per-source ``starts`` and ``counts``. A skipped source (including those rejected by ``max_aper_size`` and those whose bounding box does not overlap the data) has a zero count. The ``grid_edges`` are the ``(xmin, xmax, ymin, ymax)`` cutout grid edges relative to the source centroid, and ``kronflux`` and ``max_radius`` are copies of the inputs. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``data``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = data.shape[0] cdef Py_ssize_t nx_data = data.shape[1] _check_length(xcen.shape[0], n_src, 'xcen') _check_length(ycen.shape[0], n_src, 'ycen') _check_length(local_bkg.shape[0], n_src, 'local_bkg') _check_length(kronflux.shape[0], n_src, 'kronflux') _check_length(max_radius.shape[0], n_src, 'max_radius') _check_length(skip.shape[0], n_src, 'skip') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'data') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'data') # The clipped cutout bounding box (x0, x1, y0, y1) and the mirror # center (ccx, ccy) of each source bbox_arr = np.zeros((n_src, 6), dtype=np.intp) counts_arr = np.zeros(n_src, dtype=np.intp) starts_arr = np.zeros(n_src, dtype=np.intp) nx_arr = np.zeros(n_src, dtype=np.intp) ny_arr = np.zeros(n_src, dtype=np.intp) grid_edges_arr = np.zeros((n_src, 4)) cdef Py_ssize_t[:, ::1] bbox = bbox_arr cdef Py_ssize_t[::1] counts = counts_arr cdef Py_ssize_t[::1] starts = starts_arr cdef Py_ssize_t[::1] nx_out = nx_arr cdef Py_ssize_t[::1] ny_out = ny_arr cdef double[:, ::1] grid_edges = grid_edges_arr cdef Py_ssize_t i, x0, x1, y0, y1, nx, ny, total = 0 cdef double xc, yc, rmax, ixmin_d, ixmax_d, iymin_d, iymax_d cdef double x0_d, x1_d, y0_d, y1_d, cutout_xcen, cutout_ycen with nogil: for i in range(n_src): if skip[i]: continue xc = xcen[i] yc = ycen[i] rmax = max_radius[i] # The bounding box of the maximum-radius circle, kept as # integral doubles until it is clipped to avoid integer # overflow for sources far outside the image ixmin_d = floor(xc - rmax + 0.5) ixmax_d = ceil(xc + rmax + 0.5) iymin_d = floor(yc - rmax + 0.5) iymax_d = ceil(yc + rmax + 0.5) if (iymax_d - iymin_d) * (ixmax_d - ixmin_d) > max_aper_size: continue # Clip to the data x0_d = ixmin_d if ixmin_d > 0.0 else 0.0 x1_d = ixmax_d if ixmax_d < nx_data else nx_data y0_d = iymin_d if iymin_d > 0.0 else 0.0 y1_d = iymax_d if iymax_d < ny_data else ny_data if y0_d >= y1_d or x0_d >= x1_d: continue x0 = x0_d x1 = x1_d y0 = y0_d y1 = y1_d nx = x1 - x0 ny = y1 - y0 # The cutout-frame centroid and the mirror center of the # 'correct' method (the cutout centroid + 0.5, truncated # toward zero as Python's int() does) cutout_xcen = xc - x0 cutout_ycen = yc - y0 bbox[i, 0] = x0 bbox[i, 1] = x1 bbox[i, 2] = y0 bbox[i, 3] = y1 bbox[i, 4] = (cutout_xcen + 0.5) + x0 bbox[i, 5] = (cutout_ycen + 0.5) + y0 nx_out[i] = nx ny_out[i] = ny counts[i] = nx * ny grid_edges[i, 0] = -0.5 - cutout_xcen grid_edges[i, 1] = (nx - 0.5) - cutout_xcen grid_edges[i, 2] = -0.5 - cutout_ycen grid_edges[i, 3] = (ny - 0.5) - cutout_ycen for i in range(n_src): starts[i] = total total += counts[i] values_arr = np.empty(total) cdef double[::1] values = values_arr with nogil: for i in range(n_src): if counts[i] == 0: continue _flux_radius_cutout(&data[0, 0], &mask[0, 0], &segm[0, 0], nx_data, labels[i], bbox[i, 0], bbox[i, 1], bbox[i, 2], bbox[i, 3], bbox[i, 4], bbox[i, 5], local_bkg[i], seg_method, &values[starts[i]]) return BatchFluxRadiusArgs( values=values_arr, starts=starts_arr, counts=counts_arr, nx=nx_arr, ny=ny_arr, grid_edges=grid_edges_arr, kronflux=np.array(kronflux, dtype=np.float64), max_radius=np.array(max_radius, dtype=np.float64), use_exact=use_exact, subpixels=subpixels) ctypedef struct _FluxRadiusArgs: const double *data Py_ssize_t nx Py_ssize_t ny double xmin_e double ymin_e double dx double dy double pixel_radius int use_exact int subpixels double normflux # Radial binning of the cutout pixels (see ``_bin_flux_radius_pixels``) double bin_width Py_ssize_t n_bins const Py_ssize_t *order const Py_ssize_t *bin_starts const double *bin_cumsum cdef void _bin_flux_radius_pixels(_FluxRadiusArgs *p, Py_ssize_t *order, Py_ssize_t *bin_of, Py_ssize_t *bin_starts, double *bin_cumsum, Py_ssize_t *work) noexcept nogil: """ Bin the cutout pixels of a source by the distance of their centers from the source centroid (a counting sort), and accumulate the prefix sums of the data over the bins. This lets ``_flux_radius_objective`` add the fully-enclosed pixels of a circle in one step and evaluate the overlap fraction only for the pixels in the bins that straddle the circle boundary, instead of classifying every cutout pixel on every root-finder iteration. Parameters ---------- p : _FluxRadiusArgs * The per-source arguments. On input the grid and data members are set. On output the ``order``, ``bin_starts``, and ``bin_cumsum`` members point at the filled arrays. order : Py_ssize_t * Output of length ``nx * ny``: the flat pixel indices grouped by increasing distance bin. bin_of : Py_ssize_t * Scratch of length ``nx * ny`` (the bin of each pixel). bin_starts : Py_ssize_t * Output of length ``n_bins + 1``: the start of each bin in ``order``. bin_cumsum : double * Output of length ``n_bins + 1``: ``bin_cumsum[b]`` is the sum of the data over the pixels in the bins before ``b``. work : Py_ssize_t * Scratch of length ``n_bins`` (the per-bin fill positions). """ cdef Py_ssize_t n_pix = p.nx * p.ny cdef Py_ssize_t ix, iy, k, b, n_bins = p.n_bins cdef double pxcen, pycen, d cdef double inv_width = 1.0 / p.bin_width for b in range(n_bins + 1): bin_starts[b] = 0 # The bin of each pixel and the pixel count of each bin k = 0 for iy in range(p.ny): pycen = p.ymin_e + iy * p.dy + 0.5 * p.dy for ix in range(p.nx): pxcen = p.xmin_e + ix * p.dx + 0.5 * p.dx d = sqrt(pxcen * pxcen + pycen * pycen) b = (d * inv_width) if b >= n_bins: b = n_bins - 1 bin_of[k] = b bin_starts[b + 1] += 1 k += 1 # Convert the counts to start offsets, then place the pixels # (row-major within each bin) for b in range(n_bins): bin_starts[b + 1] += bin_starts[b] work[b] = bin_starts[b] for k in range(n_pix): b = bin_of[k] order[work[b]] = k work[b] += 1 # Prefix sums of the data over the bins bin_cumsum[0] = 0.0 for b in range(n_bins): bin_cumsum[b + 1] = bin_cumsum[b] for k in range(bin_starts[b], bin_starts[b + 1]): bin_cumsum[b + 1] += p.data[order[k]] p.order = order p.bin_starts = bin_starts p.bin_cumsum = bin_cumsum cdef double _flux_radius_objective(double r, void *args) noexcept nogil: """ Fraction-of-flux residual ``1 - flux(r) / normflux`` for the brentq root-find. The flux is ``sum(data * overlap)`` with the same per-pixel overlap arithmetic as ``circular_overlap_grid`` (grid edges relative to the source centroid). The pixels are visited through the radial bins of ``_bin_flux_radius_pixels``. Bins whose pixels all lie within ``r - pixel_radius`` of the centroid are fully enclosed (overlap fraction exactly 1, as in the interior fast path of ``circle_frac_from_d2``) and are added from the prefix sums. Bins beyond ``r + pixel_radius`` have zero overlap. The pixels of the remaining bins are evaluated individually. """ cdef _FluxRadiusArgs *p = <_FluxRadiusArgs *>args cdef Py_ssize_t b, b_lo, b_hi, k, idx cdef double flux, pymin, pxmin, frac cdef double r_inner = r - p.pixel_radius cdef double r_outer = r + p.pixel_radius # Bins entirely inside: every pixel has d < (b + 1) * width, so # d < r_inner when (b + 1) * width <= r_inner b_lo = 0 if r_inner > 0.0: b_lo = (r_inner / p.bin_width) if b_lo > p.n_bins: b_lo = p.n_bins # Bins entirely outside: every pixel has d >= b * width, so # d >= r_outer when b * width >= r_outer b_hi = (r_outer / p.bin_width) + 1 if b_hi > p.n_bins: b_hi = p.n_bins flux = p.bin_cumsum[b_lo] for b in range(b_lo, b_hi): for k in range(p.bin_starts[b], p.bin_starts[b + 1]): idx = p.order[k] pymin = p.ymin_e + (idx // p.nx) * p.dy pxmin = p.xmin_e + (idx % p.nx) * p.dx frac = _circle_pixel_frac(pxmin, pymin, p.dx, p.dy, p.pixel_radius, r, p.use_exact, p.subpixels) if frac > 0.0: flux += p.data[idx] * frac return 1.0 - flux / p.normflux def batch_flux_radius_solve(const double[::1] values, *, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] counts, const Py_ssize_t[::1] nx, const Py_ssize_t[::1] ny, const double[:, ::1] grid_edges, const double[::1] kronflux, const double[::1] max_radius, double fraction, int use_exact, int subpixels): """ Solve the fractional-flux radius for many prepared sources in one call. For each source, the circular radius enclosing ``fraction`` of the Kron flux is the root of ``1 - flux(r) / (kronflux * fraction)``, found by a bracketed Brent root-find over ``[0.1, max_radius]``. The root-find uses the SciPy ``brentq`` C routine with the `scipy.optimize.root_scalar` default tolerances (``xtol`` = 2e-12 pixels). The cutout pixels are binned once per source by their distance from the centroid, so that each root-finder evaluation adds the fully enclosed pixels from prefix sums and evaluates the overlap only for the pixels near the circle boundary (see ``_flux_radius_objective``). The enclosed flux is therefore summed in distance-binned order rather than raster order, so the root is reproducible across summation orders only to within ``xtol``, not to rounding. A bracket whose endpoints have the same sign has no (or multiple) solutions. The maximum radius is then reduced by 10% of its original value and the root-find is retried, until either a root is found or the maximum radius drops to the minimum radius (0.1), in which case NaN is returned. The source loop runs without the GIL, and the scratch buffers are local to the call, so disjoint source ranges can be solved concurrently. Parameters ---------- values : 1D ndarray of float64 (C-contiguous) The packed cleaned cutout values of all sources, each in row-major order (see ``batch_flux_radius_prepare``). starts, counts : 1D ndarray of intp (C-contiguous) The start offset of each source in ``values`` and its number of cutout pixels, with shape ``(n_sources,)``. A source with a zero count has no solution (NaN is returned). nx, ny : 1D ndarray of intp (C-contiguous) The cutout width and height of each source, with shape ``(n_sources,)``. grid_edges : 2D ndarray of float64 (C-contiguous) The ``(n_sources, 4)`` cutout grid edges ``(xmin, xmax, ymin, ymax)`` of `~photutils.geometry.circular_overlap_grid`, relative to the source centroid. kronflux : 1D ndarray of float64 (C-contiguous) The Kron flux of each source, with shape ``(n_sources,)``. max_radius : 1D ndarray of float64 (C-contiguous) The initial upper bracket radius of each source, with shape ``(n_sources,)``. fraction : float The fraction of the Kron flux at which to find the circular radius. use_exact : int Whether to compute exact overlap fractions (1) or use subpixel sampling (0). subpixels : int The number of subpixels in each dimension when ``use_exact`` is 0. Returns ------- radius : 1D `~numpy.ndarray` The circular radius enclosing the specified fraction of the Kron flux for each source, with shape ``(n_sources,)``. NaN is returned for sources with a zero count and where no solution was found. Raises ------ ValueError If a per-source array does not have the same length as ``counts``, if ``grid_edges`` does not have 4 columns, or if a source's ``starts`` and ``counts`` region extends beyond the ``values`` buffer. """ cdef Py_ssize_t n_src = counts.shape[0] for name, length in (('starts', starts.shape[0]), ('nx', nx.shape[0]), ('ny', ny.shape[0]), ('grid_edges', grid_edges.shape[0]), ('kronflux', kronflux.shape[0]), ('max_radius', max_radius.shape[0])): if length != n_src: msg = f'{name} must have the same length as counts' raise ValueError(msg) if grid_edges.shape[1] != 4: msg = 'grid_edges must have 4 columns' raise ValueError(msg) # The source loops below index the packed buffer without bounds # checks, so reject a region that does not fit in it. cdef Py_ssize_t n_values = values.shape[0] cdef Py_ssize_t i for i in range(n_src): if counts[i] < 0 or starts[i] < 0 or starts[i] + counts[i] > n_values: msg = ('the starts and counts regions must lie within the ' 'values buffer') raise ValueError(msg) radius_arr = np.full(n_src, np.nan) cdef double[::1] radius = radius_arr cdef _FluxRadiusArgs p cdef zeros_full_output full_output cdef double xmin_e, xmax_e, ymin_e, ymax_e, max_extent cdef double dx, dy, pixel_radius, bin_width, rmax, delta, result cdef bint found cdef Py_ssize_t n_bins, max_pix = 0, max_bins = 0 # Size the scratch buffers of the radial binning for the largest # cutout and bin count (local to this call, so the function is # thread-safe). The bin count uses the same arithmetic as the # source loop below. with nogil: for i in range(n_src): if counts[i] == 0: continue if counts[i] > max_pix: max_pix = counts[i] xmin_e = grid_edges[i, 0] xmax_e = grid_edges[i, 1] ymin_e = grid_edges[i, 2] ymax_e = grid_edges[i, 3] dx = (xmax_e - xmin_e) / nx[i] dy = (ymax_e - ymin_e) / ny[i] pixel_radius = 0.5 * sqrt(dx * dx + dy * dy) bin_width = 0.5 * pixel_radius max_extent = sqrt(fmax(xmin_e * xmin_e, xmax_e * xmax_e) + fmax(ymin_e * ymin_e, ymax_e * ymax_e)) n_bins = (max_extent / bin_width) + 1 if n_bins > max_bins: max_bins = n_bins order_arr = np.empty(max(max_pix, 1), dtype=np.intp) bin_of_arr = np.empty(max(max_pix, 1), dtype=np.intp) bin_starts_arr = np.empty(max_bins + 1, dtype=np.intp) bin_cumsum_arr = np.empty(max_bins + 1, dtype=np.float64) work_arr = np.empty(max(max_bins, 1), dtype=np.intp) cdef Py_ssize_t[::1] order = order_arr cdef Py_ssize_t[::1] bin_of = bin_of_arr cdef Py_ssize_t[::1] bin_starts = bin_starts_arr cdef double[::1] bin_cumsum = bin_cumsum_arr cdef Py_ssize_t[::1] work = work_arr p.use_exact = use_exact p.subpixels = subpixels with nogil: for i in range(n_src): if counts[i] == 0: continue p.data = &values[starts[i]] p.xmin_e = grid_edges[i, 0] xmax_e = grid_edges[i, 1] p.ymin_e = grid_edges[i, 2] ymax_e = grid_edges[i, 3] p.nx = nx[i] p.ny = ny[i] # The pixel size and pixel radius are derived from the grid # extent exactly as in ``circular_overlap_grid`` p.dx = (xmax_e - p.xmin_e) / p.nx p.dy = (ymax_e - p.ymin_e) / p.ny p.pixel_radius = 0.5 * sqrt(p.dx * p.dx + p.dy * p.dy) p.normflux = kronflux[i] * fraction rmax = max_radius[i] # Radial bins of a quarter pixel diagonal out to the # farthest cutout corner. The boundary band of a circle # then spans the pixel diagonal plus two bins p.bin_width = 0.5 * p.pixel_radius max_extent = sqrt(fmax(p.xmin_e * p.xmin_e, xmax_e * xmax_e) + fmax(p.ymin_e * p.ymin_e, ymax_e * ymax_e)) p.n_bins = (max_extent / p.bin_width) + 1 _bin_flux_radius_pixels(&p, &order[0], &bin_of[0], &bin_starts[0], &bin_cumsum[0], &work[0]) found = False result = NAN delta = 0.1 * rmax while rmax > 0.1 and not found: # xtol, rtol (4 * float64 eps), and maxiter are the # root_scalar(method='brentq') defaults, so the same # underlying C algorithm follows the identical # iteration sequence result = brentq(_flux_radius_objective, 0.1, rmax, &p, 2e-12, 8.881784197001252e-16, 100, &full_output) if full_output.error_num == -1: # Sign error (same-sign bracket): shrink and # retry, matching the ValueError path of # root_scalar rmax -= delta else: # Success or nonconvergence: root_scalar does # not raise on nonconvergence, so accept the # root either way found = True if not found: result = NAN radius[i] = result return radius_arr cdef inline bint _is_source_pixel(const unsigned char *mask, const seg_t *segm, Py_ssize_t nx_data, Py_ssize_t label, Py_ssize_t ix, Py_ssize_t iy, Py_ssize_t x0, Py_ssize_t x1, Py_ssize_t y0, Py_ssize_t y1) noexcept nogil: """ Whether a pixel is an unmasked pixel of the source segment, with pixels outside the bounding box treated as background. """ if ix < x0 or ix >= x1 or iy < y0 or iy >= y1: return False return (segm[iy * nx_data + ix] == label and mask[iy * nx_data + ix] == 0) cdef inline bint _is_border_pixel(const unsigned char *mask, const seg_t *segm, Py_ssize_t nx_data, Py_ssize_t label, Py_ssize_t ix, Py_ssize_t iy, Py_ssize_t x0, Py_ssize_t x1, Py_ssize_t y0, Py_ssize_t y1) noexcept nogil: """ Whether a pixel is a source pixel with at least one 4-connected neighbor that is not a source pixel (i.e., a source pixel removed by a binary erosion with a cross footprint). """ if not _is_source_pixel(mask, segm, nx_data, label, ix, iy, x0, x1, y0, y1): return False return not (_is_source_pixel(mask, segm, nx_data, label, ix - 1, iy, x0, x1, y0, y1) and _is_source_pixel(mask, segm, nx_data, label, ix + 1, iy, x0, x1, y0, y1) and _is_source_pixel(mask, segm, nx_data, label, ix, iy - 1, x0, x1, y0, y1) and _is_source_pixel(mask, segm, nx_data, label, ix, iy + 1, x0, x1, y0, y1)) def batch_perimeter(const unsigned char[:, ::1] mask, *, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax): """ Compute the border-pixel pattern histograms of the perimeter estimator for many sources in one call. For each source, the unmasked segment pixels form a binary image (zero outside the segment bounding box). Its border pixels are the source pixels removed by a binary erosion with a 4-connected cross footprint, and the border image is convolved with the ``[[10, 2, 10], [2, 1, 2], [10, 2, 10]]`` kernel at every bounding-box pixel. The histogram of the convolved values below 34 is returned. The caller applies the perimeter weights of the estimator of Benkrid et al. (2000) to it. Parameters ---------- mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked (excluded) pixels. Bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``mask``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). Returns ------- hist : 2D ndarray of intp The histogram of the convolved border values of each source, with shape ``(n_sources, 34)``. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if ``segm`` does not have the same shape as ``mask``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = mask.shape[0] cdef Py_ssize_t nx_data = mask.shape[1] _check_length(bbox_iymin.shape[0], n_src, 'bbox_iymin') _check_length(bbox_iymax.shape[0], n_src, 'bbox_iymax') _check_length(bbox_ixmin.shape[0], n_src, 'bbox_ixmin') _check_length(bbox_ixmax.shape[0], n_src, 'bbox_ixmax') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'mask') hist_arr = np.zeros((n_src, 34), dtype=np.intp) cdef Py_ssize_t[:, ::1] hist = hist_arr cdef const unsigned char *mask_ptr = &mask[0, 0] cdef const seg_t *segm_ptr = &segm[0, 0] cdef Py_ssize_t i, ix, iy, y0, y1, x0, x1, lbl, value cdef Py_ssize_t dx, dy, weight with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] for iy in range(y0, y1): for ix in range(x0, x1): value = 0 for dy in range(-1, 2): for dx in range(-1, 2): if dx == 0 and dy == 0: weight = 1 elif dx == 0 or dy == 0: weight = 2 else: weight = 10 if _is_border_pixel(mask_ptr, segm_ptr, nx_data, lbl, ix + dx, iy + dy, x0, x1, y0, y1): value += weight if value < 34: hist[i, value] += 1 return hist_arr def batch_quad_boxes(const real_t[:, ::1] data, *, const real_t[:, ::1] error, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax, int compute_err): """ Gather the 3x3 peak-pixel boxes of the quadratic centroid fit for many sources in one call. For each source, the data within its segment bounding box is treated as a cutout whose pixels outside the source segment, input-masked, or nonfinite are zero, and the first (row-major) maximum of that cutout is the peak pixel. The 3x3 box centered on the peak is returned together with the pixel variances of the box and the box pixels that enter the fit. A masked box pixel (input-masked or nonfinite data, inside or outside of the source segment) is left out of the fit. An unmasked box pixel outside of the source segment, including a pixel of a neighboring source, enters the fit with its data value and variance. Parameters ---------- data : 2D ndarray of float32 or float64 (C-contiguous) The data array. error : 2D ndarray of float32 or float64 (C-contiguous) or `None` The pixel-wise 1-sigma errors. Must have the same shape as ``data``. Required (not `None`) if ``compute_err`` is nonzero. mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked pixels, which cannot be the peak pixel and are left out of the fit. Must have the same shape as ``data``. Bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``data``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). compute_err : int If nonzero, also gather the pixel variances of the boxes. Returns ------- status : 1D ndarray of intp The per-source status: 0 if the 3x3 box was gathered, 1 if the cutout is smaller than 3x3, 2 if every cutout pixel is masked, and 3 if the peak pixel lies on the cutout edge (so no box fits). Only status 0 rows have valid boxes. peak : 2D ndarray of intp The cutout-frame ``(x, y)`` index of the peak pixel, with shape ``(n_sources, 2)``. Valid for status 0 and 3 and ``(-1, -1)`` otherwise. boxes : 2D ndarray of float64 The data values of the 3x3 box around the peak in row-major order, with shape ``(n_sources, 9)``. Zero for the box pixels that are masked and for statuses other than 0. box_var : 2D ndarray of float64 The pixel variances of the box, zero for masked pixels, with shape ``(n_sources, 9)``. All zero unless ``compute_err`` is nonzero. fitted : 2D ndarray of uint8 Whether each box pixel enters the fit, with shape ``(n_sources, 9)``. Zero for the masked pixels of the box and one otherwise. All one for statuses other than 0. Raises ------ ValueError If ``compute_err`` is nonzero and ``error`` is `None`, if a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``data``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = data.shape[0] cdef Py_ssize_t nx_data = data.shape[1] cdef bint has_error = error is not None if compute_err and not has_error: msg = 'error must be provided when compute_err is set' raise ValueError(msg) _check_length(bbox_iymin.shape[0], n_src, 'bbox_iymin') _check_length(bbox_iymax.shape[0], n_src, 'bbox_iymax') _check_length(bbox_ixmin.shape[0], n_src, 'bbox_ixmin') _check_length(bbox_ixmax.shape[0], n_src, 'bbox_ixmax') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'data') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'data') if has_error: _check_shape(error.shape[0], error.shape[1], ny_data, nx_data, 'error', 'data') status_arr = np.zeros(n_src, dtype=np.intp) peak_arr = np.full((n_src, 2), -1, dtype=np.intp) boxes_arr = np.zeros((n_src, 9)) box_var_arr = np.zeros((n_src, 9)) fitted_arr = np.ones((n_src, 9), dtype=np.uint8) cdef Py_ssize_t[::1] status = status_arr cdef Py_ssize_t[:, ::1] peak = peak_arr cdef double[:, ::1] boxes = boxes_arr cdef double[:, ::1] box_var = box_var_arr cdef unsigned char[:, ::1] fitted = fitted_arr cdef const real_t *err_ptr = NULL if has_error: err_ptr = &error[0, 0] cdef Py_ssize_t i, ix, iy, y0, y1, x0, x1, lbl, k cdef Py_ssize_t xpeak, ypeak, bx, by cdef double v, vmax, e cdef bint found with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] if y1 - y0 < 3 or x1 - x0 < 3: status[i] = 1 continue # First (row-major) maximum of the zero-filled cutout found = False vmax = 0.0 xpeak = 0 ypeak = 0 for iy in range(y0, y1): for ix in range(x0, x1): if segm[iy, ix] != lbl or mask[iy, ix] != 0: v = 0.0 else: v = data[iy, ix] found = True if (iy == y0 and ix == x0) or v > vmax: vmax = v xpeak = ix ypeak = iy if not found: status[i] = 2 continue peak[i, 0] = xpeak - x0 peak[i, 1] = ypeak - y0 if (xpeak == x0 or xpeak == x1 - 1 or ypeak == y0 or ypeak == y1 - 1): status[i] = 3 continue k = 0 for by in range(ypeak - 1, ypeak + 2): for bx in range(xpeak - 1, xpeak + 2): if mask[by, bx] != 0: fitted[i, k] = 0 else: boxes[i, k] = data[by, bx] if compute_err: e = err_ptr[by * nx_data + bx] box_var[i, k] = e * e k += 1 return status_arr, peak_arr, boxes_arr, box_var_arr, fitted_arr def batch_segment_gather(const real_t[:, ::1] values, *, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax): """ Pack the unmasked segment pixel values of many sources into one array. For each source, the values of the pixels within its segment bounding box that belong to the source segment and are unmasked are copied, in row-major order, into a packed array. A source with no such pixels contributes a single NaN. Parameters ---------- values : 2D ndarray of float32 or float64 (C-contiguous) The array to gather from (e.g., the data, error, or background array). mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked (excluded) pixels. Must have the same shape as ``values``. Bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``values``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). Returns ------- packed : 1D ndarray of float64 The packed pixel values of all sources. offsets : 1D ndarray of intp The start offset of each source in ``packed``, with shape ``(n_sources + 1,)``. The values of source ``i`` are ``packed[offsets[i]:offsets[i + 1]]``. counts : 1D ndarray of intp The number of unmasked segment pixels of each source, with shape ``(n_sources,)``. A source with a zero count occupies a single NaN in ``packed``. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``values``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = values.shape[0] cdef Py_ssize_t nx_data = values.shape[1] _check_length(bbox_iymin.shape[0], n_src, 'bbox_iymin') _check_length(bbox_iymax.shape[0], n_src, 'bbox_iymax') _check_length(bbox_ixmin.shape[0], n_src, 'bbox_ixmin') _check_length(bbox_ixmax.shape[0], n_src, 'bbox_ixmax') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'values') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'values') offsets_arr = np.zeros(n_src + 1, dtype=np.intp) counts_arr = np.zeros(n_src, dtype=np.intp) cdef Py_ssize_t[::1] offsets = offsets_arr cdef Py_ssize_t[::1] counts = counts_arr cdef Py_ssize_t i, ix, iy, y0, y1, x0, x1, lbl, n, pos # First pass: count the contributing pixels of each source with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] n = 0 for iy in range(y0, y1): for ix in range(x0, x1): if segm[iy, ix] == lbl and mask[iy, ix] == 0: n += 1 counts[i] = n offsets[i + 1] = offsets[i] + (n if n > 0 else 1) packed_arr = np.empty(offsets[n_src]) cdef double[::1] packed = packed_arr # Second pass: copy the values with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] pos = offsets[i] if counts[i] == 0: packed[pos] = NAN continue for iy in range(y0, y1): for ix in range(x0, x1): if segm[iy, ix] == lbl and mask[iy, ix] == 0: packed[pos] = values[iy, ix] pos += 1 return packed_arr, offsets_arr, counts_arr def batch_minmax_index(const real_t[:, ::1] values, *, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax): """ Find the positions of the minimum and maximum unmasked segment pixel values of many sources in one call. For each source, the unmasked pixels of its segment within its bounding box are scanned in row-major order and the cutout-frame ``(y, x)`` positions of the first minimum and the first maximum value are returned, matching the tie-breaking of ``numpy.argmin`` and ``numpy.argmax``. Parameters ---------- values : 2D ndarray of float32 or float64 (C-contiguous) The array to scan (e.g., the data array). Its unmasked segment values are assumed finite. mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked (excluded) pixels. Must have the same shape as ``values``. Bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``values``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). Returns ------- index : 2D ndarray of intp The cutout-frame positions with shape ``(n_sources, 4)`` and columns ``(y_min, x_min, y_max, x_max)`` of the first minimum and the first maximum value of each source. All four columns are -1 for a source with no unmasked segment pixels. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``values``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = values.shape[0] cdef Py_ssize_t nx_data = values.shape[1] _check_length(bbox_iymin.shape[0], n_src, 'bbox_iymin') _check_length(bbox_iymax.shape[0], n_src, 'bbox_iymax') _check_length(bbox_ixmin.shape[0], n_src, 'bbox_ixmin') _check_length(bbox_ixmax.shape[0], n_src, 'bbox_ixmax') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'values') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'values') index_arr = np.full((n_src, 4), -1, dtype=np.intp) cdef Py_ssize_t[:, ::1] index = index_arr cdef Py_ssize_t i, ix, iy, y0, y1, x0, x1, lbl cdef Py_ssize_t ymin_idx, xmin_idx, ymax_idx, xmax_idx cdef double v, vmin, vmax cdef bint found with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] found = False vmin = 0.0 vmax = 0.0 ymin_idx = xmin_idx = ymax_idx = xmax_idx = -1 for iy in range(y0, y1): for ix in range(x0, x1): if segm[iy, ix] != lbl or mask[iy, ix] != 0: continue v = values[iy, ix] # Strict comparisons keep the first occurrence of # a repeated extreme, as numpy.argmin/argmax do if not found or v < vmin: vmin = v ymin_idx = iy - y0 xmin_idx = ix - x0 if not found or v > vmax: vmax = v ymax_idx = iy - y0 xmax_idx = ix - x0 found = True index[i, 0] = ymin_idx index[i, 1] = xmin_idx index[i, 2] = ymax_idx index[i, 3] = xmax_idx return index_arr def batch_raw_moments(const real_t[:, ::1] convdata, *, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax): """ Compute the raw spatial moments for many sources in one call. The moments are computed up to 3rd order along each axis over the segment bounding box of each source, using coordinates relative to the bounding-box origin (i.e., the cutout frame). Pixels outside the source segment, input-masked pixels, nonfinite values, and negative values contribute zero. Parameters ---------- convdata : 2D ndarray of float32 or float64 (C-contiguous) The convolved data array (or the data array itself if no convolved data was input). mask : 2D ndarray of uint8 (C-contiguous) A mask array where bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. Only bit 1 excludes a pixel here. Nonfinite convolved values are excluded by their own test. Must have the same shape as ``convdata``. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``convdata``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). Returns ------- result : 3D `~numpy.ndarray` The raw moments with shape ``(n_sources, 4, 4)``. The element ``[i, p, q]`` is the sum of ``v * cy**p * cx**q`` over the included pixels of source ``i``, where ``v`` is the convolved data value and ``(cx, cy)`` are the cutout-frame pixel coordinates. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``convdata``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = convdata.shape[0] cdef Py_ssize_t nx_data = convdata.shape[1] _check_length(bbox_iymin.shape[0], n_src, 'bbox_iymin') _check_length(bbox_iymax.shape[0], n_src, 'bbox_iymax') _check_length(bbox_ixmin.shape[0], n_src, 'bbox_ixmin') _check_length(bbox_ixmax.shape[0], n_src, 'bbox_ixmax') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'convdata') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'convdata') result_arr = np.zeros((n_src, 4, 4)) cdef double[:, :, ::1] result = result_arr cdef Py_ssize_t i, ix, iy, p, q, y0, y1, x0, x1, lbl cdef double v, cx, cy cdef double xpow[4] cdef double ypow[4] with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] for iy in range(y0, y1): cy = (iy - y0) ypow[0] = 1.0 ypow[1] = cy ypow[2] = cy * cy ypow[3] = cy * cy * cy for ix in range(x0, x1): if segm[iy, ix] != lbl: continue if mask[iy, ix] & 1: continue v = convdata[iy, ix] if not isfinite(v) or v < 0.0: continue cx = (ix - x0) xpow[0] = 1.0 xpow[1] = cx xpow[2] = cx * cx xpow[3] = cx * cx * cx for p in range(4): for q in range(4): result[i, p, q] += (v * ypow[p] * xpow[q]) return result_arr def batch_central_moments(const real_t[:, ::1] convdata, *, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax, const double[::1] xcen, const double[::1] ycen): """ Compute the central spatial moments for many sources in one call. These are the translation-invariant moments up to 3rd order along each axis, i.e., the raw moments of ``batch_raw_moments`` computed with coordinates measured from the given cutout-frame centroid. The pixel-inclusion rules are identical to those of ``batch_raw_moments``. Parameters ---------- convdata : 2D ndarray of float32 or float64 (C-contiguous) The convolved data array (or the data array itself if no convolved data was input). mask : 2D ndarray of uint8 (C-contiguous) A mask array where bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. Only bit 1 excludes a pixel here. Nonfinite convolved values are excluded by their own test. Must have the same shape as ``convdata``. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``convdata``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). xcen, ycen : 1D ndarray of float64 (C-contiguous) The cutout-frame centroid of each source, with shape ``(n_sources,)``. Returns ------- result : 3D `~numpy.ndarray` The central moments with shape ``(n_sources, 4, 4)``. The element ``[i, p, q]`` is the sum of ``v * dy**p * dx**q`` over the included pixels of source ``i``, where ``v`` is the convolved data value and ``(dx, dy)`` are the cutout-frame pixel coordinates relative to the source centroid. If a source centroid is not finite, every element except ``[i, 0, 0]``, which is the plain sum of the included values, is NaN. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``convdata``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = convdata.shape[0] cdef Py_ssize_t nx_data = convdata.shape[1] _check_length(bbox_iymin.shape[0], n_src, 'bbox_iymin') _check_length(bbox_iymax.shape[0], n_src, 'bbox_iymax') _check_length(bbox_ixmin.shape[0], n_src, 'bbox_ixmin') _check_length(bbox_ixmax.shape[0], n_src, 'bbox_ixmax') _check_length(xcen.shape[0], n_src, 'xcen') _check_length(ycen.shape[0], n_src, 'ycen') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'convdata') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'convdata') result_arr = np.zeros((n_src, 4, 4)) cdef double[:, :, ::1] result = result_arr cdef Py_ssize_t i, ix, iy, p, q, y0, y1, x0, x1, lbl cdef double v, cx, cy cdef double xpow[4] cdef double ypow[4] with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] for iy in range(y0, y1): cy = (iy - y0) - ycen[i] ypow[0] = 1.0 ypow[1] = cy ypow[2] = cy * cy ypow[3] = cy * cy * cy for ix in range(x0, x1): if segm[iy, ix] != lbl: continue if mask[iy, ix] & 1: continue v = convdata[iy, ix] if not isfinite(v) or v < 0.0: continue cx = (ix - x0) - xcen[i] xpow[0] = 1.0 xpow[1] = cx xpow[2] = cx * cx xpow[3] = cx * cx * cx for p in range(4): for q in range(4): result[i, p, q] += (v * ypow[p] * xpow[q]) if not (isfinite(xcen[i]) and isfinite(ycen[i])): # A nonfinite centroid poisons every coordinate # power, so all elements except [0, 0] (the plain # flux sum) are NaN. The sums above are NaN only where # a source has at least one included pixel, so set # them explicitly. for p in range(4): for q in range(4): if p != 0 or q != 0: result[i, p, q] = NAN return result_arr def batch_moment_err(const real_t[:, ::1] error, *, const real_t[:, ::1] convdata, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] labels, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax, const double[::1] xcen, const double[::1] ycen): """ Compute the raw centroid error sums for many sources in one call. These are the unnormalized sums that the isophotal centroid error covariance is built from. A pixel contributes its error variance only if it is inside the source segment, is unmasked (neither input-masked nor nonfinite data), and contributes nonzero flux weight to the moments (a finite, strictly positive convolved value). Every other pixel contributes zero error variance. Parameters ---------- error : 2D ndarray of float32 or float64 (C-contiguous) The pixel-wise 1-sigma errors. Must have the same shape as ``convdata``. convdata : 2D ndarray of float32 or float64 (C-contiguous) The convolved data array (or the data array itself if no convolved data was input). mask : 2D ndarray of uint8 (C-contiguous) A mask array where bit 1 (value 1) marks input-masked pixels and bit 2 (value 2) marks nonfinite data pixels folded into the mask by the caller. Any nonzero value excludes the pixel. Must have the same shape as ``convdata``. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``convdata``. labels : 1D ndarray of intp (C-contiguous) The source label for each source, with shape ``(n_sources,)``. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). xcen, ycen : 1D ndarray of float64 (C-contiguous) The cutout-frame centroid of each source, with shape ``(n_sources,)``. Returns ------- result : 2D `~numpy.ndarray` The raw error sums with shape ``(n_sources, 4)`` and columns ``(sum_e2, sum_e2_dx2, sum_e2_dy2, sum_e2_dxdy)``, where ``e2`` is the pixel error variance and ``(dx, dy)`` are the cutout-frame pixel coordinates relative to the source centroid. Raises ------ ValueError If a per-source array does not have the same length as ``labels``, or if a 2D array does not have the same shape as ``error``. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t ny_data = error.shape[0] cdef Py_ssize_t nx_data = error.shape[1] _check_length(bbox_iymin.shape[0], n_src, 'bbox_iymin') _check_length(bbox_iymax.shape[0], n_src, 'bbox_iymax') _check_length(bbox_ixmin.shape[0], n_src, 'bbox_ixmin') _check_length(bbox_ixmax.shape[0], n_src, 'bbox_ixmax') _check_length(xcen.shape[0], n_src, 'xcen') _check_length(ycen.shape[0], n_src, 'ycen') _check_shape(convdata.shape[0], convdata.shape[1], ny_data, nx_data, 'convdata', 'error') _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'error') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'error') result_arr = np.zeros((n_src, 4)) cdef double[:, ::1] result = result_arr cdef Py_ssize_t i, ix, iy, y0, y1, x0, x1, lbl cdef double v, e, e2, dx, dy with nogil: for i in range(n_src): lbl = labels[i] y0 = bbox_iymin[i] y1 = bbox_iymax[i] x0 = bbox_ixmin[i] x1 = bbox_ixmax[i] for iy in range(y0, y1): dy = (iy - y0) - ycen[i] for ix in range(x0, x1): if segm[iy, ix] != lbl: continue if mask[iy, ix] != 0: continue v = convdata[iy, ix] if not isfinite(v) or v <= 0.0: continue dx = (ix - x0) - xcen[i] e = error[iy, ix] e2 = e * e result[i, 0] += e2 result[i, 1] += e2 * dx * dx result[i, 2] += e2 * dy * dy result[i, 3] += e2 * dx * dy return result_arr cdef inline void _swap_doubles(double *a, Py_ssize_t i, Py_ssize_t j) noexcept nogil: cdef double tmp = a[i] a[i] = a[j] a[j] = tmp cdef double _select_kth(double *a, Py_ssize_t n, Py_ssize_t k) noexcept nogil: """ Return the k-th smallest value (0-based) of ``a[0:n]`` by selection. The array is partially reordered in place so that on return every value in ``a[0:k]`` is <= ``a[k]`` and every value in ``a[k+1:n]`` is >= ``a[k]``. This is quickselect with a median-of-three pivot and a three-way partition: each round moves the values below the pivot to the front of the current window and the values equal to the pivot behind them, then keeps only the part that holds rank ``k``, so the expected cost is linear in ``n`` and repeated values do not degrade it. The partition passes swap unconditionally and advance their store index by the comparison result, so they contain no data-dependent branch, which matters for the unordered pixel values these windows hold. Parameters ---------- a : double * The values, as ``a[0:n]``. Reordered on return. n : Py_ssize_t The number of values. k : Py_ssize_t The rank to select, with ``0 <= k < n``. """ cdef Py_ssize_t lo = 0, hi = n - 1, i, j, mid, n_below, n_equal cdef double pivot, x while lo < hi: # Median-of-three pivot, moved to a[hi] for the partition mid = lo + (hi - lo) // 2 if a[mid] < a[lo]: _swap_doubles(a, mid, lo) if a[hi] < a[lo]: _swap_doubles(a, hi, lo) if a[hi] < a[mid]: _swap_doubles(a, hi, mid) _swap_doubles(a, mid, hi) pivot = a[hi] # Move the values below the pivot to a[lo:i] i = lo for j in range(lo, hi): x = a[j] a[j] = a[i] a[i] = x i += x < pivot n_below = i - lo if k < lo + n_below: hi = lo + n_below - 1 continue # Move the values equal to the pivot to a[i:i+n_equal]. The # pivot itself is counted from a[hi]. lo = i for j in range(lo, hi): x = a[j] a[j] = a[i] a[i] = x i += x <= pivot _swap_doubles(a, i, hi) n_equal = i - lo + 1 if k < lo + n_equal: return pivot lo = lo + n_equal return a[lo] cdef double _median_select(double *a, Py_ssize_t n) noexcept nogil: """ Return the median of ``a[0:n]`` by selection, reordering ``a``. For an even count the median is the mean of the two middle values, as for `numpy.median`. Parameters ---------- a : double * The values, as ``a[0:n]``, with ``n >= 1``. Reordered on return. n : Py_ssize_t The number of values. """ cdef Py_ssize_t k = n // 2, i cdef double upper, lower upper = _select_kth(a, n, k) if n % 2 == 1: return upper # The lower middle value is the largest of the values below rank # k, which the selection left in a[0:k] lower = a[0] for i in range(1, k): if a[i] > lower: lower = a[i] return 0.5 * (lower + upper) cdef inline void _local_background_bbox(Py_ssize_t iymin, Py_ssize_t iymax, Py_ssize_t ixmin, Py_ssize_t ixmax, Py_ssize_t width, double scale, Py_ssize_t nx_data, Py_ssize_t ny_data, Py_ssize_t *y0, Py_ssize_t *y1, Py_ssize_t *x0, Py_ssize_t *x1) noexcept nogil: """ Compute the image-clipped bounding box of the local background annulus of one source. The annulus is the rectangular annulus centered on the segment bounding box, with an inner rectangle ``scale`` times the bounding box size and an outer rectangle ``width`` pixels larger on every side. The bounding box follows the pixel-index arithmetic of `~photutils.aperture.BoundingBox.from_float` (upper bounds exclusive), clipped to the image. Parameters ---------- iymin, iymax, ixmin, ixmax : Py_ssize_t The segment bounding box (maxima exclusive). width : Py_ssize_t The annulus width in pixels. scale : double The inner rectangle size relative to the segment bounding box. nx_data, ny_data : Py_ssize_t The image shape. y0, y1, x0, x1 : Py_ssize_t * Output. The clipped annulus bounding box (maxima exclusive). """ cdef double xpos = 0.5 * (ixmin + ixmax - 1) cdef double ypos = 0.5 * (iymin + iymax - 1) cdef double half_w_out = 0.5 * ((ixmax - ixmin) * scale + 2 * width) cdef double half_h_out = 0.5 * ((iymax - iymin) * scale + 2 * width) x0[0] = floor(xpos - half_w_out + 0.5) x1[0] = ceil(xpos + half_w_out + 0.5) y0[0] = floor(ypos - half_h_out + 0.5) y1[0] = ceil(ypos + half_h_out + 0.5) if x0[0] < 0: x0[0] = 0 if y0[0] < 0: y0[0] = 0 if x1[0] > nx_data: x1[0] = nx_data if y1[0] > ny_data: y1[0] = ny_data cdef double _local_background_source(const real_t *data, const unsigned char *mask, const seg_t *segm, Py_ssize_t nx_data, Py_ssize_t ny_data, Py_ssize_t iymin, Py_ssize_t iymax, Py_ssize_t ixmin, Py_ssize_t ixmax, Py_ssize_t width, double scale, double sigma, Py_ssize_t maxiters, Py_ssize_t min_pixels, double *values, double *kept, double *work, Py_ssize_t *n_usable) noexcept nogil: """ Compute the local background of one source. The usable pixels are the pixels whose centers lie within the local background annulus (see ``_local_background_bbox``), excluding masked pixels and pixels within any source segment. Their values are sigma-clipped about the median with the standard deviation as the scale, following `astropy.stats.SigmaClip`. Each iteration computes the bounds ``median +/- sigma * std`` of the values kept so far and drops the values outside them, until an iteration drops nothing or ``maxiters`` iterations have run. The survivors are the usable values within the bounds of the final iteration, as for the astropy C implementation and its masked output. A value dropped in an earlier iteration is therefore included again when the final bounds admit it, which happens when the median moves by more than the standard deviation shrinks (typically for integer-valued data). An iteration that drops every value ends the clipping with its bounds as the final bounds, in the same way as when it is the last allowed iteration. The SExtractor background mode of the survivors is returned (see `~photutils.background.SExtractorBackground`). Parameters ---------- data : const real_t * The C-contiguous image data. mask : const unsigned char * The image mask plane, nonzero for masked pixels. segm : const seg_t * The segmentation image. nx_data, ny_data : Py_ssize_t The image shape. iymin, iymax, ixmin, ixmax : Py_ssize_t The segment bounding box (maxima exclusive). width : Py_ssize_t The annulus width in pixels. scale : double The inner rectangle size relative to the segment bounding box. sigma : double The clipping limit in units of the standard deviation. maxiters : Py_ssize_t The maximum number of clipping iterations (at least 1). min_pixels : Py_ssize_t The minimum number of usable pixels. Zero is returned for fewer pixels. values, kept, work : double * Scratch buffers of at least the annulus bounding-box area, for the usable values, the values kept by the clipping, and the median selection. n_usable : Py_ssize_t * Output for the number of usable annulus pixels. Returns ------- result : double The local background value. Zero is returned for fewer than ``min_pixels`` usable pixels. NaN is returned for no usable pixels, or when no usable value lies within the final bounds. The latter needs an iteration that drops every value, and so a ``sigma`` below 1, because the two middle values always lie within one standard deviation of the median. """ cdef double xpos = 0.5 * (ixmin + ixmax - 1) cdef double ypos = 0.5 * (iymin + iymax - 1) cdef double half_w_in = 0.5 * ((ixmax - ixmin) * scale) cdef double half_h_in = 0.5 * ((iymax - iymin) * scale) cdef double half_w_out = 0.5 * ((ixmax - ixmin) * scale + 2 * width) cdef double half_h_out = 0.5 * ((iymax - iymin) * scale + 2 * width) cdef Py_ssize_t y0, y1, x0, x1, ix, iy, pos, i, j, n, n_kept cdef Py_ssize_t iteration, nchanged cdef double dx, dy, v, total, center, mean, delta, ss, std, lower cdef double upper, lo_tight, hi_tight, median, result _local_background_bbox(iymin, iymax, ixmin, ixmax, width, scale, nx_data, ny_data, &y0, &y1, &x0, &x1) # Gather the usable annulus pixel values in row-major order, # accumulating their sum. A pixel center is inside a rectangle when # it is strictly within both half-extents, as for the 'center' # aperture mask method. n = 0 total = 0.0 for iy in range(y0, y1): dy = fabs(iy - ypos) if dy >= half_h_out: continue for ix in range(x0, x1): dx = fabs(ix - xpos) if dx >= half_w_out: continue if dx < half_w_in and dy < half_h_in: continue pos = iy * nx_data + ix if mask[pos] != 0 or segm[pos] != 0: continue v = data[pos] values[n] = v total += v n += 1 n_usable[0] = n if n < min_pixels: return 0.0 if n == 0: return NAN # Sigma clip. Each iteration centers on the median of the kept # values and drops the values outside center +/- sigma * std, # keeping the survivors in their pixel order (and summing them for # the next iteration's mean), until no value is dropped or maxiters # is reached. The median is selected from a copy, so that the sums # run in pixel order. The tightest bounds of all iterations are # tracked to detect whether the final bounds widened. for i in range(n): kept[i] = values[i] n_kept = n nchanged = 1 iteration = 0 lo_tight = -INFINITY hi_tight = INFINITY while nchanged != 0 and iteration < maxiters: iteration += 1 for i in range(n_kept): work[i] = kept[i] center = _median_select(work, n_kept) mean = total / n_kept ss = 0.0 for i in range(n_kept): delta = kept[i] - mean ss += delta * delta std = sqrt(ss / n_kept) lower = center - std * sigma upper = center + std * sigma lo_tight = fmax(lo_tight, lower) hi_tight = fmin(hi_tight, upper) j = 0 total = 0.0 for i in range(n_kept): v = kept[i] if not (v < lower) and not (v > upper): kept[j] = v total += v j += 1 nchanged = n_kept - j n_kept = j if n_kept == 0: # Every value was dropped, so the empty set has no bounds # of its own and the bounds of this iteration are final. break if nchanged == 0 and lower == lo_tight and upper == hi_tight: # The last iteration dropped nothing and its bounds are the # tightest of all iterations, so the values within them are # exactly the kept values, whose statistics were just computed. median = center else: # Either the clipping stopped with values still being dropped # (at maxiters or with every value dropped), or the final bounds # are wider on a side than an earlier iteration's, so the values # within the final bounds are gathered again from all of the # usable values. j = 0 total = 0.0 for i in range(n): v = values[i] if not (v < lower) and not (v > upper): kept[j] = v total += v j += 1 n_kept = j if n_kept == 0: return NAN for i in range(n_kept): work[i] = kept[i] median = _median_select(work, n_kept) mean = total / n_kept ss = 0.0 for i in range(n_kept): delta = kept[i] - mean ss += delta * delta std = sqrt(ss / n_kept) # SExtractor background mode of the survivors: the mean for a zero # standard deviation, the median when the mean is offset from the # median by at least 0.3 standard deviations, and otherwise # 2.5 * median - 1.5 * mean result = (2.5 * median) - (1.5 * mean) if std == 0.0: result = mean elif fabs(mean - median) / std >= 0.3: result = median return result def batch_local_background(const real_t[:, ::1] data, *, const unsigned char[:, ::1] mask, const seg_t[:, ::1] segm, const Py_ssize_t[::1] bbox_iymin, const Py_ssize_t[::1] bbox_iymax, const Py_ssize_t[::1] bbox_ixmin, const Py_ssize_t[::1] bbox_ixmax, Py_ssize_t width, double scale, double sigma, Py_ssize_t maxiters, Py_ssize_t min_pixels): """ Compute the local background of many sources from rectangular annuli around their segment bounding boxes. For each source, the pixels whose centers lie within a rectangular annulus centered on the segment bounding box are gathered, excluding masked pixels and pixels within any source segment. The inner rectangle is ``scale`` times the bounding box size and the outer rectangle is ``width`` pixels larger on every side, matching a `~photutils.aperture.RectangularAnnulus` with the 'center' mask method. The values are sigma-clipped about their median, following `astropy.stats.SigmaClip` with the standard deviation as the scale, and the SExtractor background mode of the surviving values (see `~photutils.background.SExtractorBackground`) is returned. The clipping follows the astropy C implementation (and the masked output of `astropy.stats.SigmaClip`): the surviving values are the values within the bounds of the final clipping iteration, so a value clipped in an earlier iteration is included again when the final bounds admit it. The median of each clipping iteration is found by selection rather than by sorting the values. Parameters ---------- data : 2D ndarray of float32 or float64 (C-contiguous) The image data. mask : 2D ndarray of uint8 (C-contiguous) A mask array where nonzero values indicate masked (excluded) pixels. Must have the same shape as ``data``. segm : 2D ndarray of int32 or intp (C-contiguous) The segmentation array where background pixels are zero and sources have positive integer labels. Must have the same shape as ``data``. Every nonzero pixel is excluded. bbox_iymin, bbox_iymax, bbox_ixmin, bbox_ixmax : 1D ndarray of intp The segment bounding box of each source, as C-contiguous arrays with shape ``(n_sources,)``. The maxima are exclusive (slice ``stop`` values). width : int The annulus width in pixels. scale : float The inner rectangle size relative to the segment bounding box. sigma : float The clipping limit in units of the standard deviation. maxiters : int The maximum number of clipping iterations. Must be at least 1. min_pixels : int The minimum number of usable annulus pixels. Sources with fewer pixels have a zero local background. Returns ------- result : 1D ndarray of float64 The local background of each source, with shape ``(n_sources,)``. A source with fewer than ``min_pixels`` usable annulus pixels has a zero local background. A source with no usable annulus pixels (when ``min_pixels`` is 0), or with no usable value within the final clipping bounds (possible only for ``sigma`` below 1), has a NaN local background. n_pixels : 1D ndarray of intp The number of usable annulus pixels of each source, with shape ``(n_sources,)``. Raises ------ ValueError If a per-source array does not have the same length as ``bbox_iymin``, if a 2D array does not have the same shape as ``data``, or if ``maxiters`` is less than 1. """ cdef Py_ssize_t n_src = bbox_iymin.shape[0] cdef Py_ssize_t ny_data = data.shape[0] cdef Py_ssize_t nx_data = data.shape[1] for name, arr in (('bbox_iymax', bbox_iymax), ('bbox_ixmin', bbox_ixmin), ('bbox_ixmax', bbox_ixmax)): if arr.shape[0] != n_src: msg = f'{name} must have the same length as bbox_iymin' raise ValueError(msg) _check_shape(mask.shape[0], mask.shape[1], ny_data, nx_data, 'mask', 'data') _check_shape(segm.shape[0], segm.shape[1], ny_data, nx_data, 'segm', 'data') if maxiters < 1: msg = 'maxiters must be at least 1' raise ValueError(msg) result_arr = np.empty(n_src, dtype=np.float64) n_pixels_arr = np.empty(n_src, dtype=np.intp) cdef double[::1] result = result_arr cdef Py_ssize_t[::1] n_pixels = n_pixels_arr if n_src == 0: return result_arr, n_pixels_arr # Scratch buffers sized to an upper bound on the annulus bounding # box area, capped at the image area. An outer rectangle side is # scale times the segment bounding box side plus the annulus width # on each side, and rounding its edges outward to pixel indices # adds less than one pixel on each side (see # ``_local_background_bbox``). cdef Py_ssize_t max_h = np.max(np.subtract(bbox_iymax, bbox_iymin)) cdef Py_ssize_t max_w = np.max(np.subtract(bbox_ixmax, bbox_ixmin)) cdef Py_ssize_t max_area = (((max_h * scale) + 2 * width + 2) * ((max_w * scale) + 2 * width + 2)) if max_area > ny_data * nx_data: max_area = ny_data * nx_data cdef Py_ssize_t i values_arr = np.empty(max_area, dtype=np.float64) kept_arr = np.empty(max_area, dtype=np.float64) work_arr = np.empty(max_area, dtype=np.float64) cdef double[::1] values = values_arr cdef double[::1] kept = kept_arr cdef double[::1] work = work_arr with nogil: for i in range(n_src): result[i] = _local_background_source( &data[0, 0], &mask[0, 0], &segm[0, 0], nx_data, ny_data, bbox_iymin[i], bbox_iymax[i], bbox_ixmin[i], bbox_ixmax[i], width, scale, sigma, maxiters, min_pixels, &values[0], &kept[0], &work[0], &n_pixels[i]) return result_arr, n_pixels_arr astropy-photutils-24be529/photutils/segmentation/_batch_results.py000066400000000000000000000030401526452531300257100ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Result containers of the batch segmentation Cython drivers. """ from typing import NamedTuple import numpy as np __all__ = [] class BatchFluxRadiusArgs(NamedTuple): """ The prepared per-source inputs of the flux-radius root-find, as returned by ``batch_flux_radius_prepare`` and consumed by ``batch_flux_radius_solve`` (see their docstrings). The cleaned cutouts of all sources are packed into one buffer. A source that cannot have a solution has a zero pixel count. """ values: np.ndarray """The packed cleaned, background-subtracted cutout values of all sources, each in row-major order.""" starts: np.ndarray """The start offset of each source in ``values``.""" counts: np.ndarray """The number of cutout pixels of each source (zero for a source skipped by the preparation).""" nx: np.ndarray """The cutout width of each source.""" ny: np.ndarray """The cutout height of each source.""" grid_edges: np.ndarray """The ``(n_sources, 4)`` grid edges ``(xmin, xmax, ymin, ymax)`` of each cutout, relative to the source centroid.""" kronflux: np.ndarray """The Kron flux of each source.""" max_radius: np.ndarray """The initial upper bracket radius of each source.""" use_exact: int """Whether the root-find uses exact overlap fractions (1) or subpixel sampling (0).""" subpixels: int """The number of subpixels in each dimension when ``use_exact`` is 0.""" astropy-photutils-24be529/photutils/segmentation/_deblend_markers.pyx000066400000000000000000001151001526452531300263600ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Cython kernel that builds the deblending watershed markers from a level-quantized source cutout in a single component-tree pass. The multithreshold marker construction is defined level by level. At each threshold level, the connected components (with fewer than ``n_pixels`` pixels removed) of the pixels above the threshold are the candidate sources, and a marker is replaced by its components at a higher level whenever it contains at least two of them. Computing this directly requires one full labeling pass per level. This kernel instead builds the quantized component tree once, by adding pixels in decreasing level order to a union-find structure and snapshotting the components at each populated level (levels between populated values have identical components and are provably no-ops in the per-level construction, so they need no snapshots). The marker set is then derived by descending the tree. Starting from the components of the lowest level that has at least two components with ``n_pixels`` or more pixels, each marker is replaced by its sufficiently large components at the first higher level that contains at least two of them. The resulting markers are identical to the per-level construction, including the raster-scan ordering of the marker labels. The multithreshold levels themselves are computed by the caller (vectorized in NumPy over all the sources of a chunk, see ``photutils.segmentation.deblend``) and passed in, so that they are bitwise identical to the pure-Python reference implementation on every platform. The kernels here compute the per-source data extrema and flux, quantize each cutout against its levels, and build the markers. The kernels run without the GIL and use no global mutable state, so this module is safe to use from multiple threads, including on free-threaded Python builds. """ import numpy as np from libc.math cimport NAN, isnan from libc.stdlib cimport free, malloc, realloc __all__ = ['deblend_markers_chunk', 'deblend_source_stats', 'make_deblend_markers'] ctypedef fused data_t: float double ctypedef fused segm_t: int long long cdef struct _Nodes: # Growable per-node storage for the component tree int* level int* repr_pix int* child int* sib int* area double* flux unsigned char* kept Py_ssize_t n Py_ssize_t cap cdef struct _SaddleArgs: # Inputs of the saddle contrast criterion (see _markers_core). # Only enabled is read when the criterion is not in use bint enabled const double* thresholds double limit double* posimg double* fsum cdef inline bint _nodes_grow(_Nodes* nodes) noexcept nogil: """ Double the node storage capacity and return False on failure. """ cdef Py_ssize_t cap = nodes.cap * 2 cdef int* level = realloc(nodes.level, cap * sizeof(int)) if level == NULL: return False nodes.level = level cdef int* repr_pix = realloc(nodes.repr_pix, cap * sizeof(int)) if repr_pix == NULL: return False nodes.repr_pix = repr_pix cdef int* child = realloc(nodes.child, cap * sizeof(int)) if child == NULL: return False nodes.child = child cdef int* sib = realloc(nodes.sib, cap * sizeof(int)) if sib == NULL: return False nodes.sib = sib cdef int* area = realloc(nodes.area, cap * sizeof(int)) if area == NULL: return False nodes.area = area cdef double* flux = realloc(nodes.flux, cap * sizeof(double)) if flux == NULL: return False nodes.flux = flux cdef unsigned char* kept = realloc( nodes.kept, cap * sizeof(unsigned char)) if kept == NULL: return False nodes.kept = kept nodes.cap = cap return True cdef inline Py_ssize_t _find(int* parent, Py_ssize_t x) noexcept nogil: """ Return the union-find root of ``x``, compressing the path. """ cdef Py_ssize_t root = x cdef Py_ssize_t nxt while parent[root] != root: root = parent[root] while parent[x] != root: nxt = parent[x] parent[x] = root x = nxt return root cdef inline void _union(int* parent, int* size, double* fsum, Py_ssize_t a, Py_ssize_t b) noexcept nogil: """ Union the components containing ``a`` and ``b`` by size. The per-component flux sums are also merged when a ``fsum`` workspace is provided. """ cdef Py_ssize_t ra = _find(parent, a) cdef Py_ssize_t rb = _find(parent, b) cdef Py_ssize_t tmp if ra == rb: return if size[ra] < size[rb]: tmp = ra ra = rb rb = tmp parent[rb] = ra size[ra] += size[rb] if fsum != NULL: fsum[ra] += fsum[rb] cdef Py_ssize_t _markers_core(const int* qflat, Py_ssize_t ny, Py_ssize_t nx, int n_pixels, bint conn8, int* parent, int* size, const _SaddleArgs* saddle, unsigned char* added, int* stamp, int* node_of_root, int* order, int* flood, int* markers) noexcept nogil: """ Build the deblending markers for one level-quantized cutout. The caller must provide the pixel-sized workspace arrays with ``added`` all zero and ``stamp`` all -1. Both invariants are restored before returning. The marker labels are written into ``markers`` at the pixels of the cutout that have a nonzero quantized level (the caller is responsible for treating the other ``markers`` entries as zero). With ``saddle.enabled``, the markers are instead selected with the saddle contrast criterion, which requires the ``posimg`` pixel values, the ``thresholds`` level values, the ``fsum`` pixel-sized workspace, and the ``limit`` flux (the contrast times the total source flux) of the ``saddle`` arguments. A branch passes when the flux it holds above the level at which it separates from its siblings exceeds the limit. The component fluxes are accumulated in the union-find merge order, so they can differ in the last bits from the raster-order sums of the pure-Python reference implementation. This only matters for a branch whose flux above its level equals the limit to within rounding. Returns the number of markers (0 if the source does not split), or -1 if a memory allocation failed. """ cdef Py_ssize_t n_tot = ny * nx cdef Py_ssize_t result = 0 cdef Py_ssize_t p, i, j, s, m, batch_start, nid, pid, root, nb cdef Py_ssize_t prev_lo, prev_hi, n_snaps, kept_cnt, base cdef Py_ssize_t node, chain, child, first_kept, n_kept_children cdef Py_ssize_t n_stack, n_final, fs, vthr, mn, rank, n_pass cdef Py_ssize_t py, px, dy, dx cdef int v, lbl cdef bint splitting cdef double prominence # Unpack the saddle criterion inputs cdef bint use_saddle = saddle.enabled cdef const double* posimg = NULL cdef const double* thresholds = NULL cdef double* fsum = NULL cdef double saddle_limit = 0.0 if use_saddle: posimg = saddle.posimg thresholds = saddle.thresholds fsum = saddle.fsum saddle_limit = saddle.limit # Count the active (nonzero) pixels and the maximum level cdef int qmax = 0 cdef Py_ssize_t n_active = 0 for p in range(n_tot): if qflat[p] > 0: n_active += 1 if qflat[p] > qmax: qmax = qflat[p] if n_active == 0: return 0 cdef _Nodes nodes nodes.n = 0 nodes.cap = 256 nodes.level = malloc(nodes.cap * sizeof(int)) nodes.repr_pix = malloc(nodes.cap * sizeof(int)) nodes.child = malloc(nodes.cap * sizeof(int)) nodes.sib = malloc(nodes.cap * sizeof(int)) nodes.area = malloc(nodes.cap * sizeof(int)) nodes.flux = malloc(nodes.cap * sizeof(double)) nodes.kept = malloc(nodes.cap * sizeof(unsigned char)) # Counting-sort bookkeeping and per-snapshot bookkeeping cdef Py_ssize_t* counts = malloc( (qmax + 1) * sizeof(Py_ssize_t)) cdef Py_ssize_t* fill = malloc( (qmax + 1) * sizeof(Py_ssize_t)) cdef Py_ssize_t* snap_lo = malloc( qmax * sizeof(Py_ssize_t)) cdef Py_ssize_t* snap_hi = malloc( qmax * sizeof(Py_ssize_t)) cdef Py_ssize_t* snap_kept = malloc( qmax * sizeof(Py_ssize_t)) cdef Py_ssize_t* stack = NULL cdef Py_ssize_t* final = NULL cdef Py_ssize_t* min_pix = NULL cdef Py_ssize_t* order_rank = NULL cdef int* lut = NULL cdef unsigned char* passer = NULL cdef unsigned char* has_split = NULL if (nodes.level == NULL or nodes.repr_pix == NULL or nodes.child == NULL or nodes.sib == NULL or nodes.area == NULL or nodes.flux == NULL or nodes.kept == NULL or counts == NULL or fill == NULL or snap_lo == NULL or snap_hi == NULL or snap_kept == NULL): result = -1 if result == 0: # Sort the active pixels by decreasing level with a counting # sort. Pixels within a level stay in raster order for v in range(qmax + 1): counts[v] = 0 for p in range(n_tot): if qflat[p] > 0: counts[qflat[p]] += 1 i = 0 for v in range(qmax, 0, -1): fill[v] = i i += counts[v] for p in range(n_tot): v = qflat[p] if v > 0: order[fill[v]] = p fill[v] += 1 # The caller only guarantees zeros at inactive pixels for i in range(n_active): markers[order[i]] = 0 # Build the component tree by adding pixels in decreasing # level order, snapshotting components at populated levels i = 0 prev_lo = 0 prev_hi = 0 n_snaps = 0 while i < n_active: v = qflat[order[i]] batch_start = i # Add pixels at this level and union with added neighbors while i < n_active and qflat[order[i]] == v: p = order[i] parent[p] = p size[p] = 1 if use_saddle: fsum[p] = posimg[p] added[p] = 1 py = p // nx px = p % nx for dy in range(-1, 2): if py + dy < 0 or py + dy >= ny: continue for dx in range(-1, 2): if px + dx < 0 or px + dx >= nx: continue if dy == 0 and dx == 0: continue if not conn8 and dy != 0 and dx != 0: continue nb = p + dy * nx + dx if added[nb]: _union(parent, size, fsum, p, nb) i += 1 # Snapshot the components of the level set with threshold # index v - 1 and attach the previous (higher) level # components as children kept_cnt = 0 for nid in range(prev_lo, prev_hi): root = _find(parent, nodes.repr_pix[nid]) if stamp[root] != n_snaps: stamp[root] = n_snaps if nodes.n == nodes.cap and not _nodes_grow(&nodes): result = -1 break nodes.level[nodes.n] = v - 1 nodes.repr_pix[nodes.n] = root nodes.child[nodes.n] = -1 nodes.area[nodes.n] = size[root] if use_saddle: nodes.flux[nodes.n] = fsum[root] nodes.kept[nodes.n] = size[root] >= n_pixels kept_cnt += nodes.kept[nodes.n] node_of_root[root] = nodes.n nodes.n += 1 pid = node_of_root[root] nodes.sib[nid] = nodes.child[pid] nodes.child[pid] = nid if result != 0: break # Create nodes for components new at this level for j in range(batch_start, i): root = _find(parent, order[j]) if stamp[root] != n_snaps: stamp[root] = n_snaps if nodes.n == nodes.cap and not _nodes_grow(&nodes): result = -1 break nodes.level[nodes.n] = v - 1 nodes.repr_pix[nodes.n] = root nodes.child[nodes.n] = -1 nodes.area[nodes.n] = size[root] if use_saddle: nodes.flux[nodes.n] = fsum[root] nodes.kept[nodes.n] = size[root] >= n_pixels kept_cnt += nodes.kept[nodes.n] node_of_root[root] = nodes.n nodes.n += 1 if result != 0: break snap_lo[n_snaps] = prev_hi snap_hi[n_snaps] = nodes.n snap_kept[n_snaps] = kept_cnt prev_lo = prev_hi prev_hi = nodes.n n_snaps += 1 n_final = 0 if result == 0 and use_saddle: # Saddle criterion: a junction splits where at least two # sufficiently large children hold more flux above the level # at which they separate than the saddle limit. The markers # are the passing children of splitting junctions that contain # no deeper split themselves. final = malloc(nodes.n * sizeof(Py_ssize_t)) passer = malloc(nodes.n * sizeof(unsigned char)) has_split = malloc(nodes.n * sizeof(unsigned char)) if final == NULL or passer == NULL or has_split == NULL: result = -1 else: # A branch passes when the flux it holds above the level at # which it separates from its siblings (the level above its # parent junction) exceeds the saddle limit. A node # represents its component over every level between two # populated ones, so this is the lowest of those levels, # as in the per-level reference construction. The # lowest-level components separate at the first threshold # level. Every other node is the child of exactly one node for nid in range(snap_lo[n_snaps - 1], snap_hi[n_snaps - 1]): prominence = (nodes.flux[nid] - thresholds[0] * nodes.area[nid]) passer[nid] = (nodes.kept[nid] and prominence > saddle_limit) for nid in range(nodes.n): child = nodes.child[nid] while child != -1: prominence = (nodes.flux[child] - thresholds[nodes.level[nid] + 1] * nodes.area[child]) passer[child] = (nodes.kept[child] and prominence > saddle_limit) child = nodes.sib[child] # Node ids ascend from the treetops down, so children are # always visited before their parents for nid in range(nodes.n): n_pass = 0 child = nodes.child[nid] while child != -1: if passer[child]: n_pass += 1 child = nodes.sib[child] splitting = n_pass >= 2 if splitting: child = nodes.child[nid] while child != -1: if passer[child] and not has_split[child]: final[n_final] = child n_final += 1 child = nodes.sib[child] has_split[nid] = splitting child = nodes.child[nid] while child != -1: if has_split[child]: has_split[nid] = 1 child = nodes.sib[child] # The lowest-level components form a virtual root # junction at the first threshold level n_pass = 0 for nid in range(snap_lo[n_snaps - 1], snap_hi[n_snaps - 1]): if passer[nid]: n_pass += 1 if n_pass >= 2: for nid in range(snap_lo[n_snaps - 1], snap_hi[n_snaps - 1]): if passer[nid] and not has_split[nid]: final[n_final] = nid n_final += 1 if n_final < 2: # A splitting junction always yields at least two # markers, so this only happens with none at all n_final = 0 elif result == 0: # Find the base snapshot: the lowest level with at least two # sufficiently large components base = -1 for s in range(n_snaps - 1, -1, -1): if snap_kept[s] >= 2: base = s break if base >= 0: # Descend the tree: replace each marker by its # sufficiently large components at the first higher # level containing at least two of them stack = malloc(nodes.n * sizeof(Py_ssize_t)) final = malloc(nodes.n * sizeof(Py_ssize_t)) if stack == NULL or final == NULL: result = -1 else: n_stack = 0 for nid in range(snap_lo[base], snap_hi[base]): if nodes.kept[nid]: stack[n_stack] = nid n_stack += 1 while n_stack > 0: n_stack -= 1 node = stack[n_stack] chain = node while True: n_kept_children = 0 first_kept = -1 child = nodes.child[chain] while child != -1: if nodes.kept[child]: n_kept_children += 1 if n_kept_children == 1: first_kept = child elif n_kept_children == 2: stack[n_stack] = first_kept n_stack += 1 stack[n_stack] = child n_stack += 1 else: stack[n_stack] = child n_stack += 1 child = nodes.sib[child] if n_kept_children >= 2: break if n_kept_children == 1: chain = first_kept continue final[n_final] = node n_final += 1 break if result == 0 and n_final >= 2: # Paint each final marker by flood filling its component from # the recorded representative pixel. The marker regions are # disjoint, so each pixel is visited at most once. min_pix = malloc(n_final * sizeof(Py_ssize_t)) order_rank = malloc(n_final * sizeof(Py_ssize_t)) lut = malloc((n_final + 1) * sizeof(int)) if min_pix == NULL or order_rank == NULL or lut == NULL: result = -1 if result == 0: for m in range(n_final): nid = final[m] vthr = nodes.level[nid] + 1 lbl = (m + 1) p = nodes.repr_pix[nid] markers[p] = lbl flood[0] = p fs = 1 mn = p while fs > 0: fs -= 1 p = flood[fs] if p < mn: mn = p py = p // nx px = p % nx for dy in range(-1, 2): if py + dy < 0 or py + dy >= ny: continue for dx in range(-1, 2): if px + dx < 0 or px + dx >= nx: continue if dy == 0 and dx == 0: continue if not conn8 and dy != 0 and dx != 0: continue nb = p + dy * nx + dx if markers[nb] == 0 and qflat[nb] >= vthr: markers[nb] = lbl flood[fs] = nb fs += 1 min_pix[m] = mn # Relabel the markers in raster-scan order of their first # pixels (an insertion sort, as the first pixels are # distinct), matching the ordering that per-level labeling # would produce. for m in range(n_final): order_rank[m] = m for m in range(1, n_final): j = m while (j > 0 and min_pix[order_rank[j - 1]] > min_pix[order_rank[j]]): rank = order_rank[j - 1] order_rank[j - 1] = order_rank[j] order_rank[j] = rank j -= 1 lut[0] = 0 for m in range(n_final): lut[order_rank[m] + 1] = (m + 1) for i in range(n_active): p = order[i] if markers[p] != 0: markers[p] = lut[markers[p]] result = n_final # Restore the workspace invariants for the added and stamp # arrays. The active pixels are found from qflat rather than from # order, which is not filled if an early allocation failed for p in range(n_tot): if qflat[p] > 0: added[p] = 0 stamp[p] = -1 free(nodes.level) free(nodes.repr_pix) free(nodes.child) free(nodes.sib) free(nodes.area) free(nodes.flux) free(nodes.kept) free(passer) free(has_split) free(counts) free(fill) free(snap_lo) free(snap_hi) free(snap_kept) free(stack) free(final) free(min_pix) free(order_rank) free(lut) return result def make_deblend_markers(const int[:, ::1] quantized, int n_pixels, int connectivity): """ Build the deblending watershed markers for a single source. This single-source entry point is exported only for the pure-Python reference implementation in ``_deblend_reference`` and the cross-implementation tests. Production deblending goes through ``deblend_markers_chunk``. Parameters ---------- quantized : 2D int `~numpy.ndarray` The level-quantized source cutout. Each pixel value is the number of multithreshold levels below the pixel data value (i.e., the pixel is above threshold level ``i`` if ``i < quantized``). Pixels outside the source segment and NaN pixels must be 0. n_pixels : int The minimum number of connected pixels an above-threshold component must have to be considered a source. connectivity : {8, 4} The pixel connectivity. Returns ------- markers : 2D int `~numpy.ndarray` The marker image, with markers labeled consecutively from 1 in raster-scan order. All values are 0 if no markers were found. n_markers : int The number of markers. Zero means no threshold level had at least two components with ``n_pixels`` or more pixels. """ cdef Py_ssize_t ny = quantized.shape[0] cdef Py_ssize_t nx = quantized.shape[1] cdef Py_ssize_t n_tot = ny * nx markers_arr = np.zeros((ny, nx), dtype=np.int32) parent_arr = np.empty(n_tot, dtype=np.int32) size_arr = np.zeros(n_tot, dtype=np.int32) added_arr = np.zeros(n_tot, dtype=np.uint8) stamp_arr = np.full(n_tot, -1, dtype=np.int32) node_of_root_arr = np.zeros(n_tot, dtype=np.int32) order_arr = np.empty(n_tot, dtype=np.int32) flood_arr = np.empty(n_tot, dtype=np.int32) cdef const int* qflat = &quantized[0, 0] cdef int[:, ::1] markers_mv = markers_arr cdef int[::1] parent_mv = parent_arr cdef int[::1] size_mv = size_arr cdef unsigned char[::1] added_mv = added_arr cdef int[::1] stamp_mv = stamp_arr cdef int[::1] node_of_root_mv = node_of_root_arr cdef int[::1] order_mv = order_arr cdef int[::1] flood_mv = flood_arr cdef _SaddleArgs saddle saddle.enabled = False saddle.thresholds = NULL saddle.limit = 0.0 saddle.posimg = NULL saddle.fsum = NULL cdef Py_ssize_t n_markers with nogil: n_markers = _markers_core(qflat, ny, nx, n_pixels, connectivity == 8, &parent_mv[0], &size_mv[0], &saddle, &added_mv[0], &stamp_mv[0], &node_of_root_mv[0], &order_mv[0], &flood_mv[0], &markers_mv[0, 0]) if n_markers < 0: raise MemoryError return markers_arr, int(n_markers) cdef inline int _count_below(const double* thresholds, int n_levels, double value) noexcept nogil: """ Return the number of thresholds strictly below ``value``. This matches ``np.searchsorted(thresholds, value, side='left')`` for non-NaN values. """ cdef int lo = 0 cdef int hi = n_levels cdef int mid while lo < hi: mid = (lo + hi) // 2 if thresholds[mid] < value: lo = mid + 1 else: hi = mid return lo cdef void _source_stats(const data_t* data, const segm_t* segm, Py_ssize_t img_nx, long long label, Py_ssize_t y0, Py_ssize_t y1, Py_ssize_t x0, Py_ssize_t x1, double* smin, double* smax, double* ssum) noexcept nogil: """ Compute the minimum, maximum, and flux of one source segment. NaN pixels are excluded. The flux is accumulated sequentially in float64 in raster order. The minimum and maximum are NaN, and the flux is 0, if the pixels of the segment are all NaN. """ cdef Py_ssize_t iy, ix, idx cdef double value cdef bint has_value = False smin[0] = NAN smax[0] = NAN ssum[0] = 0.0 for iy in range(y1 - y0): for ix in range(x1 - x0): idx = (y0 + iy) * img_nx + x0 + ix if segm[idx] != label: continue value = data[idx] if isnan(value): continue ssum[0] += value if not has_value: smin[0] = value smax[0] = value has_value = True elif value < smin[0]: smin[0] = value elif value > smax[0]: smax[0] = value def deblend_source_stats(const data_t[:, ::1] data, const segm_t[:, ::1] segm_data, const long long[::1] labels, const long long[::1] y0, const long long[::1] y1, const long long[::1] x0, const long long[::1] x1): """ Compute the minimum, maximum, and flux of each source segment. NaN pixels are excluded. The minimum and maximum are identical to the ``nanmin`` and ``nanmax`` reductions over the segment pixels. The flux is accumulated sequentially in float64 in raster order, which is what ``np.cumsum(values, dtype=np.float64)[-1]`` computes. Parameters ---------- data : 2D float `~numpy.ndarray` The full data array. segm_data : 2D int `~numpy.ndarray` The full segmentation array. labels : 1D int64 `~numpy.ndarray` The label of each source. y0, y1, x0, x1 : 1D int64 `~numpy.ndarray` The bounding-box slice bounds of each source. Returns ------- source_min, source_max, source_sum : 1D float64 `~numpy.ndarray` The minimum, maximum, and flux of each source segment. The minimum and maximum are NaN, and the flux is 0, for a segment whose pixels are all NaN. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t img_nx = data.shape[1] cdef Py_ssize_t isrc smin_arr = np.empty(n_src, dtype=np.float64) smax_arr = np.empty(n_src, dtype=np.float64) ssum_arr = np.empty(n_src, dtype=np.float64) cdef double[::1] smin_mv = smin_arr cdef double[::1] smax_mv = smax_arr cdef double[::1] ssum_mv = ssum_arr with nogil: for isrc in range(n_src): _source_stats(&data[0, 0], &segm_data[0, 0], img_nx, labels[isrc], y0[isrc], y1[isrc], x0[isrc], x1[isrc], &smin_mv[isrc], &smax_mv[isrc], &ssum_mv[isrc]) return smin_arr, smax_arr, ssum_arr cdef Py_ssize_t _source_markers(const data_t* data, const segm_t* segm, Py_ssize_t img_nx, long long label, Py_ssize_t y0, Py_ssize_t y1, Py_ssize_t x0, Py_ssize_t x1, int n_pixels, bint conn8, int n_levels, const double* thresholds, const _SaddleArgs* saddle, int* q, int* parent, int* size, unsigned char* added, int* stamp, int* node_of_root, int* order, int* flood, int* markers) noexcept nogil: """ Build the deblending markers for one source of a chunk. Quantizes the cutout against the multithreshold levels of the source (the number of levels strictly below each pixel value, with the pixels outside the segment and the NaN pixels at 0) and runs the component-tree core with the selected contrast criterion. Returns the number of markers (0 means the source does not split) or -1 if a memory allocation failed. """ cdef Py_ssize_t ny_c = y1 - y0 cdef Py_ssize_t nx_c = x1 - x0 cdef Py_ssize_t iy, ix, idx cdef double value for iy in range(ny_c): for ix in range(nx_c): idx = (y0 + iy) * img_nx + x0 + ix if saddle.enabled: saddle.posimg[iy * nx_c + ix] = data[idx] if segm[idx] != label: q[iy * nx_c + ix] = 0 continue value = data[idx] if isnan(value): q[iy * nx_c + ix] = 0 continue q[iy * nx_c + ix] = _count_below(thresholds, n_levels, value) return _markers_core(q, ny_c, nx_c, n_pixels, conn8, parent, size, saddle, added, stamp, node_of_root, order, flood, markers) def deblend_markers_chunk(const data_t[:, ::1] data, const segm_t[:, ::1] segm_data, const long long[::1] labels, const long long[::1] y0, const long long[::1] y1, const long long[::1] x0, const long long[::1] x1, const double[:, ::1] thresholds, int[::1] packed, const Py_ssize_t[::1] starts, *, int n_pixels, int connectivity, int max_markers, saddle_limits=None): """ Build the deblending watershed markers for a chunk of sources. Each cutout is quantized against its own multithreshold levels and its markers are built by the component-tree kernel, in compiled code that releases the GIL and reuses one workspace sized to the largest cutout in the chunk. Parameters ---------- data : 2D float `~numpy.ndarray` The full data array. segm_data : 2D int `~numpy.ndarray` The full segmentation array. labels : 1D int64 `~numpy.ndarray` The label of each source in the chunk. y0, y1, x0, x1 : 1D int64 `~numpy.ndarray` The bounding-box slice bounds of each source. thresholds : 2D float64 `~numpy.ndarray` The multithreshold levels of each source, with shape ``(n_sources, n_levels)`` and ascending along the second axis. packed : 1D int32 `~numpy.ndarray` The buffer that receives the marker image of every source. The region of source ``i`` starts at ``starts[i]`` and holds its ``(y1 - y0) * (x1 - x0)`` cutout pixels in raster order. Each region is zeroed, and then the markers are written for the sources that split into two or more markers (and no more than ``max_markers`` when it is not negative). The other regions are left at zero. starts : 1D intp `~numpy.ndarray` The start index of each source's region in ``packed``. n_pixels : int The minimum number of connected pixels an above-threshold component must have to be considered a source. connectivity : {8, 4} The pixel connectivity. max_markers : int The number of markers above which the marker image of a source is not kept (its region is left at zero). Its marker count is still returned, so that the caller can retry the source with other levels. A negative value disables the limit. saddle_limits : 1D float64 `~numpy.ndarray` or `None`, optional If given, the markers are selected with the saddle contrast criterion instead of building all candidate markers. A junction of the component tree splits only where at least two sufficiently large components each hold more flux above the level at which they separate (the threshold level above that of the junction) than this per-source limit (the contrast times the total source flux). Returns ------- n_markers : 1D intp `~numpy.ndarray` The number of markers found for each source. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t img_nx = data.shape[1] cdef int n_levels = thresholds.shape[1] cdef Py_ssize_t isrc, n_tot, max_ntot, ny_c, nx_c, start, p cdef Py_ssize_t n_markers cdef bint use_saddle = saddle_limits is not None if thresholds.shape[0] != n_src: msg = 'thresholds must have one row per source' raise ValueError(msg) if (y0.shape[0] != n_src or y1.shape[0] != n_src or x0.shape[0] != n_src or x1.shape[0] != n_src or starts.shape[0] != n_src): msg = 'every per-source array must have one entry per source' raise ValueError(msg) max_ntot = 1 for isrc in range(n_src): n_tot = (y1[isrc] - y0[isrc]) * (x1[isrc] - x0[isrc]) if starts[isrc] + n_tot > packed.shape[0]: msg = 'packed is too small for the source regions' raise ValueError(msg) if n_tot > max_ntot: max_ntot = n_tot q_arr = np.empty(max_ntot, dtype=np.int32) parent_arr = np.empty(max_ntot, dtype=np.int32) size_arr = np.zeros(max_ntot, dtype=np.int32) added_arr = np.zeros(max_ntot, dtype=np.uint8) stamp_arr = np.full(max_ntot, -1, dtype=np.int32) node_of_root_arr = np.zeros(max_ntot, dtype=np.int32) order_arr = np.empty(max_ntot, dtype=np.int32) flood_arr = np.empty(max_ntot, dtype=np.int32) n_markers_arr = np.zeros(n_src, dtype=np.intp) cdef int[::1] q_mv = q_arr cdef int[::1] parent_mv = parent_arr cdef int[::1] size_mv = size_arr cdef unsigned char[::1] added_mv = added_arr cdef int[::1] stamp_mv = stamp_arr cdef int[::1] node_of_root_mv = node_of_root_arr cdef int[::1] order_mv = order_arr cdef int[::1] flood_mv = flood_arr cdef Py_ssize_t[::1] n_markers_mv = n_markers_arr # The saddle criterion inputs, with workspaces used only by it cdef _SaddleArgs saddle cdef const double[::1] saddle_limits_mv = None cdef double[::1] posimg_mv = None cdef double[::1] fsum_mv = None saddle.enabled = use_saddle saddle.thresholds = NULL saddle.limit = 0.0 saddle.posimg = NULL saddle.fsum = NULL if use_saddle: saddle_limits_mv = saddle_limits posimg_arr = np.empty(max_ntot, dtype=np.float64) fsum_arr = np.empty(max_ntot, dtype=np.float64) posimg_mv = posimg_arr fsum_mv = fsum_arr saddle.posimg = &posimg_mv[0] saddle.fsum = &fsum_mv[0] # The whole chunk runs without the GIL, so that the threads of a # multithreaded deblend do not contend for it once per source. cdef bint failed = False with nogil: for isrc in range(n_src): ny_c = y1[isrc] - y0[isrc] nx_c = x1[isrc] - x0[isrc] n_tot = ny_c * nx_c start = starts[isrc] if use_saddle: saddle.limit = saddle_limits_mv[isrc] saddle.thresholds = &thresholds[isrc, 0] for p in range(n_tot): packed[start + p] = 0 n_markers = _source_markers( &data[0, 0], &segm_data[0, 0], img_nx, labels[isrc], y0[isrc], y1[isrc], x0[isrc], x1[isrc], n_pixels, connectivity == 8, n_levels, &thresholds[isrc, 0], &saddle, &q_mv[0], &parent_mv[0], &size_mv[0], &added_mv[0], &stamp_mv[0], &node_of_root_mv[0], &order_mv[0], &flood_mv[0], &packed[start]) if n_markers < 0: failed = True break if n_markers < 2 or (max_markers >= 0 and n_markers > max_markers): for p in range(n_tot): packed[start + p] = 0 n_markers_mv[isrc] = n_markers if failed: raise MemoryError return n_markers_arr astropy-photutils-24be529/photutils/segmentation/_deblend_reference.py000066400000000000000000000626131526452531300264740ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Pure-Python reference implementation of the deblending pipeline. This module is a pure-Python mirror of the compiled deblending pipeline used by :func:`~photutils.segmentation.deblend_sources`, kept for verification and debugging. It is not used in production. It must track the compiled (Cython) semantics exactly, which is enforced by the cross-implementation tests in ``photutils/segmentation/tests/test_deblend.py``. """ from functools import cached_property import numpy as np from scipy.ndimage import label as ndi_label from scipy.ndimage import sum_labels from photutils.segmentation._deblend_markers import make_deblend_markers from photutils.segmentation._deblend_watershed import deblend_watershed from photutils.segmentation.core import _get_labels from photutils.segmentation.deblend import _MAX_MARKERS, _create_relabel_map from photutils.utils._stats import nanmax, nanmin def _detect_sources_deblend(data, threshold, n_pixels, *, footprint, segment_mask): """ Detect sources for a single multithreshold level during deblending. This is the deblending analogue of `photutils.segmentation.detect._detect_sources`. It differs in that the detected segments keep their (possibly nonconsecutive) label numbers from `~scipy.ndimage.label`, the small segments are removed with a bincount-based area filter (the per-label cutout loop used by ``_detect_sources`` has a fixed per-label overhead that dominates for the small cutouts and the many calls made during deblending), and `None` is returned when fewer than two segments are found. Parameters ---------- data : 2D `~numpy.ndarray` The cutout data array for a single source. threshold : float The data value to be used for the detection threshold. n_pixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be detected. footprint : array_like A footprint that defines feature connections. segment_mask : 2D bool `~numpy.ndarray` A boolean mask of the source segment, with the same shape as ``data``. Pixels outside the segment will not be included in any source. Returns ------- segment_img : 2D int `~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 fewer than two sources are found then `None` is returned. """ # NaN values compare as False, so NaN pixels are never included # in any source. The comparison is never empty because the # deblending thresholds are strictly below the source maximum. segment_img = data > threshold segment_img &= segment_mask segment_img, n_labels = ndi_label(segment_img, structure=footprint) # Remove objects with less than n_pixels areas = np.bincount(segment_img.ravel()) keep = areas >= n_pixels keep[0] = False n_keep = np.count_nonzero(keep) if n_keep <= 1: return None if n_keep < n_labels: label_map = np.where( keep, np.arange(areas.size, dtype=segment_img.dtype), 0) segment_img = label_map[segment_img] return segment_img def _raster_relabel(markers): """ Relabel a marker image consecutively in raster-scan order of the first pixel of each marker. Parameters ---------- markers : 2D int `~numpy.ndarray` The marker image. Returns ------- result : 2D int `~numpy.ndarray` The relabeled marker image. """ flat = markers.ravel() labels, first = np.unique(flat, return_index=True) keep = labels > 0 labels = labels[keep] order = np.argsort(first[keep]) lut = np.zeros(labels.max() + 1, dtype=np.int32) lut[labels[order]] = np.arange(1, labels.size + 1) return lut[markers] 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 : `~photutils.segmentation.deblend._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.contrast_method = deblend_params.contrast_method 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) # The flux is accumulated sequentially in float64 in raster # order, exactly as the compiled stats kernel does. np.cumsum # is a sequential accumulation, unlike the pairwise np.nansum finite_values = data_values[~np.isnan(data_values)] if finite_values.size > 0: self.source_sum = float(np.cumsum(finite_values, dtype=np.float64)[-1]) else: self.source_sum = 0.0 self.warnings = {} @cached_property 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) @cached_property 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 multilevel detection thresholds for the source. Note that this method has side effects. When the mode is "exponential" and the source minimum is nonpositive, it changes ``self.mode`` to "sinh" and records the fallback in ``self.warnings``. Later calls (e.g., from ``make_markers``) therefore use the fallback mode, mirroring the sticky per-source mode fallback in the compiled pipeline. Returns ------- thresholds : 1D `~numpy.ndarray` The multilevel detection thresholds for the source. """ if self.mode == 'exponential' and self.source_min <= 0: # The exponential spacing is undefined for nonpositive # minima. sinh keeps the levels concentrated near the # source minimum and is defined for any data values. self.warnings['nonposmin'] = 'nonpositive minimum' self.mode = 'sinh' 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 at each multithreshold level of the source. This method is useful for debugging and testing. Returns ------- segments : list of 2D `~numpy.ndarray` or `None` A list of segmentation images, one for each threshold. `None` is returned for thresholds that do not have more than one label. """ thresholds = self.compute_thresholds() segms = [] for threshold in thresholds: segm = _detect_sources_deblend(self.data, threshold, self.n_pixels, footprint=self.footprint, segment_mask=self.segment_mask) segms.append(segm) return segms def make_markers(self): """ Make markers (possible sources) for the watershed algorithm. With the basin contrast criterion, the markers are built from a single component-tree pass over the level-quantized cutout (see `~photutils.segmentation._deblend_markers.make_deblend_markers`), which produces the same marker regions in the same order as the per-level multithreshold construction of ``make_markers_per_level``. The label numbers can differ. With the saddle criterion, the markers are selected level by level in Python by ``make_saddle_markers``. Returns ------- markers : 2D `~numpy.ndarray` or `None` A segmentation image that contains markers for possible sources. With the basin criterion, `None` is returned if no threshold level has two or more sources. With the saddle criterion, `None` is returned if there are fewer than two markers. """ thresholds = self.compute_thresholds() if self.contrast_method == 'saddle': return self.make_saddle_markers(thresholds) # A pixel is above threshold level i if i < quantized. NaN # pixels compare as False against every threshold. quantized = np.searchsorted(thresholds, self.data.ravel(), side='left') quantized = quantized.reshape(self.data.shape).astype(np.int32) quantized[~self.segment_mask | np.isnan(self.data)] = 0 connectivity = 8 if self.footprint[0, 0] else 4 markers, n_markers = make_deblend_markers(quantized, self.n_pixels, connectivity) if n_markers == 0: return None return markers def make_saddle_markers(self, thresholds): """ Make markers with the saddle contrast criterion, level by level. The connected components above each threshold level form a tree. A junction splits where at least two of its children (the components at the next level) have at least ``n_pixels`` pixels and each hold more flux above their own level than the saddle limit, the contrast times the total source flux. The markers are the passing children of the splitting junctions that contain no deeper split, labeled consecutively in raster-scan order of their first pixel, as the compiled kernel labels them. This method labels the cutout once per level and scans it once per component, so its cost grows with the number of levels times the number of components times the cutout area. It is a reference for testing, not meant for large cutouts. Parameters ---------- thresholds : 1D `~numpy.ndarray` The multithreshold levels. Returns ------- markers : 2D int `~numpy.ndarray` or `None` The marker image, or `None` if there are fewer than two markers. """ # A Python float, as the compiled chunk driver computes it limit = self.contrast * float(self.source_sum) level_labels = [ ndi_label((self.data > threshold) & self.segment_mask, structure=self.footprint)[0] for threshold in thresholds] passer = {} children = {} all_nodes = [] for level, labeled in enumerate(level_labels): for comp in np.unique(labeled[labeled > 0]): node = (level, int(comp)) all_nodes.append(node) region = labeled == comp area = int(region.sum()) flux = float(np.sum(self.data[region], dtype=np.float64)) prominence = flux - thresholds[level] * area passer[node] = area >= self.n_pixels and prominence > limit children[node] = [] if level > 0: parent_comp = int(level_labels[level - 1][region][0]) children[(level - 1, parent_comp)].append(node) def is_splitting(kids): return sum(passer[kid] for kid in kids) >= 2 has_split = {} for node in reversed(all_nodes): # children before parents has_split[node] = (is_splitting(children[node]) or any(has_split[kid] for kid in children[node])) # The components at the first level form a virtual root # junction root_children = [(0, int(comp)) for comp in np.unique(level_labels[0] [level_labels[0] > 0])] junctions = [root_children] + [children[node] for node in all_nodes] markers = [] for kids in junctions: if is_splitting(kids): markers.extend(kid for kid in kids if passer[kid] and not has_split[kid]) if len(markers) < 2: return None out = np.zeros(self.data.shape, dtype=np.int32) for index, (level, comp) in enumerate(markers): out[level_labels[level] == comp] = index + 1 return _raster_relabel(out) def make_markers_per_level(self): """ Make markers with the per-level multithreshold construction. The markers are refined level by level, replacing every marker that splits at the next-higher threshold by its children (see ``make_marker_segment``). The last list element contains the final markers, which have the same regions in the same order as the ``make_markers`` result. The label numbers can differ. This method is useful for debugging and testing. Returns ------- markers : list of (2D `~numpy.ndarray` or `None`) The segmentation marker image for the first threshold level, followed by the marker image after each higher threshold level that had two or more segments. The first element is `None` if the first threshold level had fewer than two segments. """ thresholds = self.compute_thresholds() segm_lower = _detect_sources_deblend( self.data, thresholds[0], self.n_pixels, footprint=self.footprint, segment_mask=self.segment_mask) all_segms = [segm_lower] for threshold in thresholds[1:]: segm_upper = _detect_sources_deblend( self.data, threshold, self.n_pixels, footprint=self.footprint, segment_mask=self.segment_mask) if segm_upper is None: # 0 or 1 labels continue segm_lower = self.make_marker_segment(segm_lower, segm_upper) all_segms.append(segm_lower) return all_segms def make_marker_segment(self, segment_lower, segment_upper): """ Make markers (possible sources) for the watershed algorithm. Parameters ---------- segment_lower : 2D `~numpy.ndarray` or `None` The "lower" threshold level segmentation image. If `None`, then ``segment_upper`` is returned. segment_upper : 2D `~numpy.ndarray` The next-higher threshold level segmentation image. Returns ------- markers : 2D `~numpy.ndarray` A segmentation image that contains 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 # Count the upper-level children of each lower-level label from # the unique (lower, upper) label pairs, encoded as a combined # integer key. both = (segment_lower > 0) & (segment_upper > 0) stride = np.int64(np.max(segment_upper)) + 1 keys = segment_lower[both].astype(np.int64) * stride keys += segment_upper[both] parents, n_children = np.unique(np.unique(keys) // stride, return_counts=True) multi_parents = parents[n_children >= 2] if multi_parents.size == 0: return segment_lower # Replace each multichild parent by its children. Pixels of # the parent mask that are below the upper threshold are unset. # Single-child parents are kept as is (maximizing the marker # size). replace_lut = np.zeros(np.max(segment_lower) + 1, dtype=bool) replace_lut[multi_parents] = True replace = replace_lut[segment_lower] markers = segment_lower > 0 markers[replace] = segment_upper[replace] > 0 # Convert bool markers to integer labels return ndi_label(markers, structure=self.footprint)[0] def apply_watershed(self, markers): """ Apply the watershed algorithm to the source markers. Parameters ---------- markers : 2D int `~numpy.ndarray` The marker image for the source. 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. """ # Deblend using watershed. If any source does not meet the # contrast criterion, then remove the faintest such source(s) # and repeat until all sources meet the contrast criterion. # With the saddle criterion the markers are already # contrast-selected, so a single pass is run. data_neg = np.ascontiguousarray(-self.data, dtype=np.float64) # NaN pixels are flooded after all finite pixels, as in the # compiled contrast loop data_neg[np.isnan(data_neg)] = np.inf connectivity = 8 if self.footprint[0, 0] else 4 remove_marker = True while remove_marker: markers = deblend_watershed(data_neg, markers, self.segment_mask, connectivity) labels = _get_labels(markers) if labels.size == 1 or self.contrast_method == 'saddle': remove_marker = False else: # NaN pixels are excluded from the basin fluxes flux = np.where(np.isnan(self.data), 0.0, self.data) flux_frac = (sum_labels(flux, markers, index=labels) / self.source_sum) remove_marker = any(flux_frac < self.contrast) if remove_marker: self._remove_faint_markers(markers, labels, flux_frac) return markers def _remove_faint_markers(self, markers, labels, flux_frac): """ Remove the faintest below-contrast marker(s) in place. The faintest marker is always removed. When the source data values are all nonnegative, the largest batch of the faintest markers is removed whose total flux fraction is below both the contrast and the next-faintest marker flux fraction. Removing such a batch in one step is equivalent to removing its markers one at a time. Every batch member stays below the contrast no matter how the flux of the other removed members is redistributed (their total is below the contrast), and markers outside the batch can only become brighter, so the faintest below-contrast marker always lies inside the batch until the batch is exhausted. Faint markers that do not fit in such a batch are removed one at a time because several faint sources could combine to meet the contrast criterion. Parameters ---------- markers : 2D int `~numpy.ndarray` The watershed-labeled marker image, modified in place. labels : 1D `~numpy.ndarray` The sorted marker labels. flux_frac : 1D `~numpy.ndarray` The flux fraction in each marker basin, in the same order as ``labels``. """ if self.source_min >= 0 and labels.size > 2: order = np.argsort(flux_frac) sorted_frac = flux_frac[order] csum = np.cumsum(sorted_frac) # A batch of the n faintest markers (2 <= n < N) is valid # if its total flux fraction is below both the contrast # and the next-faintest marker flux fraction. batch_ok = ((csum[1:-1] < self.contrast) & (csum[1:-1] < sorted_frac[2:])) valid = np.nonzero(batch_ok)[0] if valid.size > 0: n_remove = int(valid[-1]) + 2 remove_lut = np.zeros(int(labels[-1]) + 1, dtype=bool) remove_lut[labels[order[:n_remove]]] = True markers[remove_lut[markers]] = 0 return markers[markers == labels[np.argmin(flux_frac)]] = 0 def deblend_from_markers(self, markers): """ Deblend the source given its precomputed watershed markers. Parameters ---------- markers : 2D int `~numpy.ndarray` The marker image for the source. Returns ------- segment_data : 2D int `~numpy.ndarray` or `None` A 2D int array containing the deblended source labels. The source labels are consecutive starting at 1. `None` is returned if only one source remains after applying the contrast criterion. """ # 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 deblend_source(self): """ Deblend a single labeled source. This method computes the markers and the watershed steps entirely in Python, mirroring what ``deblend_sources`` computes through the compiled chunk driver. It is useful for debugging and testing. Returns ------- segment_data : 2D int `~numpy.ndarray` or `None` 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. # This mostly affects the "exponential" mode, where there are # many levels at low thresholds, so here we try again with # "linear" mode. The saddle criterion needs no retry because # its markers are already contrast-selected and its watershed # runs once. n_labels = len(_get_labels(markers)) if (self.mode != 'linear' and self.contrast_method != 'saddle' and n_labels > _MAX_MARKERS): del markers # free memory self.warnings['n_markers'] = 'too many markers' self.mode = 'linear' markers = self.make_markers() if markers is None: return None return self.deblend_from_markers(markers) astropy-photutils-24be529/photutils/segmentation/_deblend_watershed.pyx000066400000000000000000000712571526452531300267200ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Cython marker-based watershed kernel for source deblending. This implements the classic priority-flood watershed (Soille 1990) used for deblending. Pixels are flooded from the markers in order of increasing image value, with the queue-entry age breaking ties so that plateaus are split between the markers that reach them first. The neighbors are visited with the orthogonal neighbors before the diagonal ones, each group in raster order. A pixel enters the queue with the larger of its own value and the flooding cost of the pixel it was reached from, so that the basins and plateaus below the current flood level are distributed between the contesting markers by queue-entry age. The kernel does no per-call validation, padding, or cropping, which would otherwise dominate the cost for small cutouts. The flood order is only defined for ordered image values, so the deblending entry point maps NaN data pixels to a +inf flooding cost. Such pixels are assigned to the basins that reach them after all the finite pixels have been assigned. The flood core runs without the GIL and uses no global mutable state, so this module is safe to use from multiple threads, including on free-threaded Python builds. """ import numpy as np from libc.math cimport INFINITY, isnan from libc.stdlib cimport free, malloc __all__ = ['apply_relabel_map', 'deblend_contrast_chunk', 'deblend_watershed', 'find_present_labels', 'write_deblended_labels'] ctypedef fused data_t: float double ctypedef fused segm_t: int long long cdef struct _Heap: # Binary min-heap on (value, age) double* value long long* age int* index Py_ssize_t n cdef inline bint _heap_less(_Heap* heap, Py_ssize_t a, Py_ssize_t b) noexcept nogil: """ Compare two heap slots by (value, age). """ if heap.value[a] != heap.value[b]: return heap.value[a] < heap.value[b] return heap.age[a] < heap.age[b] cdef inline void _heap_swap(_Heap* heap, Py_ssize_t a, Py_ssize_t b) noexcept nogil: """ Swap two heap slots. """ cdef double value = heap.value[a] cdef long long age = heap.age[a] cdef int index = heap.index[a] heap.value[a] = heap.value[b] heap.age[a] = heap.age[b] heap.index[a] = heap.index[b] heap.value[b] = value heap.age[b] = age heap.index[b] = index cdef inline void _heap_push(_Heap* heap, double value, long long age, Py_ssize_t index) noexcept nogil: """ Push an item onto the heap. """ cdef Py_ssize_t pos = heap.n cdef Py_ssize_t parent heap.value[pos] = value heap.age[pos] = age heap.index[pos] = index heap.n += 1 while pos > 0: parent = (pos - 1) // 2 if _heap_less(heap, pos, parent): _heap_swap(heap, pos, parent) pos = parent else: break cdef inline Py_ssize_t _heap_pop(_Heap* heap, double* value) noexcept nogil: """ Pop the smallest item, returning its pixel index and value. """ cdef Py_ssize_t result = heap.index[0] cdef Py_ssize_t pos = 0 cdef Py_ssize_t child value[0] = heap.value[0] heap.n -= 1 if heap.n > 0: _heap_swap(heap, 0, heap.n) while True: child = 2 * pos + 1 if child >= heap.n: break if (child + 1 < heap.n and _heap_less(heap, child + 1, child)): child += 1 if _heap_less(heap, child, pos): _heap_swap(heap, pos, child) pos = child else: break return result cdef int _watershed_core(const double* image, unsigned char* mask, int* output, Py_ssize_t ny, Py_ssize_t nx, bint conn8) noexcept nogil: """ Flood the masked pixels of ``output`` from its nonzero markers. Returns 0 on success or -1 if a memory allocation failed. """ # Neighbor offsets: orthogonal neighbors before diagonal ones, # each group in raster order (the stable distance ordering) cdef Py_ssize_t[8] off_y cdef Py_ssize_t[8] off_x cdef Py_ssize_t n_off off_y[0] = -1 off_x[0] = 0 off_y[1] = 0 off_x[1] = -1 off_y[2] = 0 off_x[2] = 1 off_y[3] = 1 off_x[3] = 0 if conn8: off_y[4] = -1 off_x[4] = -1 off_y[5] = -1 off_x[5] = 1 off_y[6] = 1 off_x[6] = -1 off_y[7] = 1 off_x[7] = 1 n_off = 8 else: n_off = 4 # Each masked pixel enters the queue at most once cdef Py_ssize_t n_tot = ny * nx cdef Py_ssize_t cap = 0 cdef Py_ssize_t p for p in range(n_tot): if mask[p]: cap += 1 if cap == 0: return 0 cdef _Heap heap heap.n = 0 heap.value = malloc(cap * sizeof(double)) heap.age = malloc(cap * sizeof(long long)) heap.index = malloc(cap * sizeof(int)) if heap.value == NULL or heap.age == NULL or heap.index == NULL: free(heap.value) free(heap.age) free(heap.index) return -1 # Push the marker pixels in raster order with age 0 cdef long long age = 0 for p in range(n_tot): if output[p] != 0 and mask[p]: _heap_push(&heap, image[p], 0, p) cdef Py_ssize_t py, px, ny_i, nx_i, nb, i cdef double pop_value, push_value while heap.n > 0: p = _heap_pop(&heap, &pop_value) py = p // nx px = p % nx for i in range(n_off): ny_i = py + off_y[i] nx_i = px + off_x[i] if ny_i < 0 or ny_i >= ny or nx_i < 0 or nx_i >= nx: continue nb = ny_i * nx + nx_i if not mask[nb]: continue if output[nb] != 0: continue age += 1 output[nb] = output[p] # The flooding cost of a pixel is at least the cost of # the pixel it was reached from, so that plateaus and # basins below the current flood level are distributed # by queue-entry age between contesting markers push_value = image[nb] if push_value < pop_value: push_value = pop_value _heap_push(&heap, push_value, age, nb) free(heap.value) free(heap.age) free(heap.index) return 0 def deblend_watershed(image, markers, mask, connectivity): """ Compute the marker-based watershed of an image. The image is flooded from the markers, which must lie inside the mask, without watershed lines between the basins and without any per-call validation, padding, or cropping. This entry point is exported only for the pure-Python reference implementation in ``_deblend_reference`` and the cross-implementation tests. The production contrast loop calls the watershed core directly through ``deblend_contrast_chunk``. Parameters ---------- image : 2D `~numpy.ndarray` The image to flood (the lowest values are flooded first). It must not contain NaN values. The deblending callers map NaN data pixels to +inf so that they are flooded last. markers : 2D int `~numpy.ndarray` The marker image. Zero means not a marker. All markers must lie inside the mask. mask : 2D bool `~numpy.ndarray` Only pixels where the mask is `True` are labeled. connectivity : {8, 4} The pixel connectivity. Returns ------- output : 2D int `~numpy.ndarray` The labeled basins, with the same shape as ``image``. """ image_arr = np.ascontiguousarray(image, dtype=np.float64) output_arr = np.array(markers, dtype=np.int32, copy=True, order='C') mask_arr = np.ascontiguousarray(mask, dtype=np.uint8) cdef const double[:, ::1] image_mv = image_arr cdef int[:, ::1] output_mv = output_arr cdef unsigned char[:, ::1] mask_mv = mask_arr cdef Py_ssize_t ny = image_mv.shape[0] cdef Py_ssize_t nx = image_mv.shape[1] cdef bint conn8 = connectivity == 8 cdef int status with nogil: status = _watershed_core(&image_mv[0, 0], &mask_mv[0, 0], &output_mv[0, 0], ny, nx, conn8) if status < 0: raise MemoryError return output_arr cdef int _contrast_core(const double* posimg, const double* negimg, unsigned char* mask, int* output, Py_ssize_t ny, Py_ssize_t nx, bint conn8, double contrast, double source_sum, double source_min, Py_ssize_t n_max_labels, bint apply_contrast) noexcept nogil: """ Run the watershed contrast loop for one source in place. ``output`` holds the markers on input and the final relabeled (consecutive from 1) basins on output. Returns the number of final labels, or -1 if a memory allocation failed, or -2 if the flooded basins do not cover the segment mask (a connectivity mismatch). When ``apply_contrast`` is false, the below-contrast basin removal is skipped and a single watershed pass is run. The loop replicates the NumPy implementation operation for operation. The basin fluxes are accumulated in raster order in float64 (as np.bincount does), the below-contrast markers are removed one at a time or in the largest provably equivalent batch of the faintest markers, and NaN basin fluxes compare false against every threshold, with np.argmin's first-NaN behavior for the single-marker removal. The only divergence is the order of bitwise-equal basin fluxes in the batch sort (NumPy uses an unstable sort there, while this uses a stable one). """ cdef Py_ssize_t n_tot = ny * nx cdef Py_ssize_t n_cap = n_max_labels + 1 cdef long long* counts = malloc( n_cap * sizeof(long long)) cdef double* flux = malloc(n_cap * sizeof(double)) cdef int* lab = malloc(n_cap * sizeof(int)) cdef double* frac = malloc(n_cap * sizeof(double)) cdef Py_ssize_t* order = malloc( n_cap * sizeof(Py_ssize_t)) cdef double* csum = malloc(n_cap * sizeof(double)) cdef unsigned char* removed = malloc( n_cap * sizeof(unsigned char)) cdef int* lut = malloc(n_cap * sizeof(int)) cdef Py_ssize_t p, i, j, k, n_labels, n_remove, min_idx, last_ok cdef Py_ssize_t pos cdef int status, current cdef bint remove_marker, a_nan, b_nan cdef double value_a, value_b if (counts == NULL or flux == NULL or lab == NULL or frac == NULL or order == NULL or csum == NULL or removed == NULL or lut == NULL): status = -1 else: status = 0 n_labels = 0 while status == 0: status = _watershed_core(negimg, mask, output, ny, nx, conn8) if status != 0: break # Present labels (ascending) and their fluxes, accumulated # in raster order in float64 as np.bincount does. for i in range(n_cap): counts[i] = 0 flux[i] = 0.0 for p in range(n_tot): current = output[p] if current != 0: counts[current] += 1 flux[current] += posimg[p] n_labels = 0 for i in range(1, n_cap): if counts[i] > 0: lab[n_labels] = i frac[n_labels] = flux[i] / source_sum n_labels += 1 if n_labels == 1: # only 1 source left break remove_marker = False if apply_contrast: for i in range(n_labels): if frac[i] < contrast: remove_marker = True break if not remove_marker: break # Remove the faintest below-contrast marker(s). See # _SingleSourceDeblender._remove_faint_markers. n_remove = 1 if source_min >= 0 and n_labels > 2: # Stable sort of the label positions by flux fraction, # with NaN values sorting to the end. for i in range(n_labels): order[i] = i for i in range(1, n_labels): j = i while j > 0: value_a = frac[order[j]] value_b = frac[order[j - 1]] a_nan = isnan(value_a) b_nan = isnan(value_b) if ((not a_nan and b_nan) or (not a_nan and value_a < value_b)): pos = order[j - 1] order[j - 1] = order[j] order[j] = pos j -= 1 else: break csum[0] = frac[order[0]] for i in range(1, n_labels): csum[i] = csum[i - 1] + frac[order[i]] # A batch of the n faintest markers (2 <= n < N) is # valid if its total flux fraction is below both the # contrast and the next-faintest marker flux fraction. last_ok = -1 for k in range(1, n_labels - 1): if (csum[k] < contrast and csum[k] < frac[order[k + 1]]): last_ok = k if last_ok >= 0: n_remove = last_ok + 1 for i in range(n_cap): removed[i] = 0 if n_remove == 1: # np.argmin: the first NaN if any, else the first minimum min_idx = -1 for i in range(n_labels): if isnan(frac[i]): min_idx = i break if min_idx == -1: min_idx = 0 for i in range(1, n_labels): if frac[i] < frac[min_idx]: min_idx = i removed[lab[min_idx]] = 1 else: for j in range(n_remove): removed[lab[order[j]]] = 1 for p in range(n_tot): if output[p] != 0 and removed[output[p]]: output[p] = 0 if status == 0: # The flooded basins must cover the segment mask exactly # (they cannot with mismatched detection and deblending # connectivities). for p in range(n_tot): if mask[p] and output[p] == 0: status = -2 break if status == 0: # Relabel the surviving labels consecutively from 1 in # ascending label order. for i in range(n_labels): lut[lab[i]] = (i + 1) for p in range(n_tot): if output[p] != 0: output[p] = lut[output[p]] status = n_labels free(counts) free(flux) free(lab) free(frac) free(order) free(csum) free(removed) free(lut) return status def deblend_contrast_chunk(const data_t[:, ::1] data, const segm_t[:, ::1] segm_data, const long long[::1] labels, const long long[::1] y0, const long long[::1] y1, const long long[::1] x0, const long long[::1] x1, int[::1] packed, const Py_ssize_t[::1] starts, const Py_ssize_t[::1] n_markers, *, int connectivity, double contrast, const double[::1] source_sum, const double[::1] source_min, bint apply_contrast): """ Apply the watershed contrast loop to the markers of a chunk of sources in place. For every source with two or more markers, the flooding, the basin flux measurements, the below-contrast marker removal, and the final consecutive relabeling run in compiled code that releases the GIL, reusing one workspace sized to the largest cutout in the chunk. The results are identical to the per-step NumPy implementation in ``_SingleSourceDeblender``. Parameters ---------- data : 2D float `~numpy.ndarray` The full data array. NaN pixels within a segment are flooded after all the finite pixels, so they are assigned to a neighboring basin. They are excluded from the flux of that basin, as in the NumPy implementation. segm_data : 2D int `~numpy.ndarray` The full segmentation array. labels : 1D int64 `~numpy.ndarray` The label of each source in the chunk. y0, y1, x0, x1 : 1D int64 `~numpy.ndarray` The bounding-box slice bounds of each source. packed : 1D int32 `~numpy.ndarray` The packed marker buffer written by ``deblend_markers_chunk``. On output, the region of every source that deblends holds its final labels, consecutive from 1. The region of a source that was flooded and has fewer than two final labels is zeroed. The regions of the skipped sources are not modified (``deblend_markers_chunk`` leaves them at zero). starts : 1D intp `~numpy.ndarray` The start index of each source's region in ``packed``. n_markers : 1D intp `~numpy.ndarray` The number of markers of each source. Sources with fewer than two markers are skipped. connectivity : {8, 4} The pixel connectivity. contrast : float The contrast criterion (the minimum fraction of the total source flux that a watershed basin must contain). source_sum, source_min : 1D float64 `~numpy.ndarray` The flux and the minimum data value of each source segment (NaN pixels excluded). apply_contrast : bool Whether to apply the contrast criterion. If `False`, a single watershed pass is run with no basin removal. This is used with the saddle contrast criterion, where the markers are already contrast-selected. Returns ------- n_labels : 1D intp `~numpy.ndarray` The number of final labels of each source. It is 0 for the skipped sources and 1 for the sources whose basins were all but one removed. The packed regions of the latter are zeroed. The regions of the skipped sources are not modified (``deblend_markers_chunk`` leaves them at zero). Raises ------ ValueError If the flooded basins of a source do not cover its segment, which happens when the detection and deblending connectivities differ, or if the per-source arrays do not have one entry per source, or if ``packed`` is too small for the source regions. MemoryError If a workspace allocation fails. """ cdef Py_ssize_t n_src = labels.shape[0] cdef Py_ssize_t img_nx = data.shape[1] cdef Py_ssize_t isrc, n_tot, max_ntot, ny_c, nx_c, start cdef Py_ssize_t iy, ix, idx, p, n_max_labels cdef Py_ssize_t failed = -1 cdef int status = 0 cdef bint conn8 = connectivity == 8 if (y0.shape[0] != n_src or y1.shape[0] != n_src or x0.shape[0] != n_src or x1.shape[0] != n_src or starts.shape[0] != n_src or n_markers.shape[0] != n_src or source_sum.shape[0] != n_src or source_min.shape[0] != n_src): msg = 'every per-source array must have one entry per source' raise ValueError(msg) # The workspaces are sized to the largest cutout that is deblended max_ntot = 1 for isrc in range(n_src): n_tot = (y1[isrc] - y0[isrc]) * (x1[isrc] - x0[isrc]) if starts[isrc] + n_tot > packed.shape[0]: msg = 'packed is too small for the source regions' raise ValueError(msg) if n_markers[isrc] >= 2 and n_tot > max_ntot: max_ntot = n_tot posimg_arr = np.empty(max_ntot, dtype=np.float64) negimg_arr = np.empty(max_ntot, dtype=np.float64) mask_arr = np.empty(max_ntot, dtype=np.uint8) output_arr = np.empty(max_ntot, dtype=np.int32) n_labels_arr = np.zeros(n_src, dtype=np.intp) cdef double[::1] posimg_mv = posimg_arr cdef double[::1] negimg_mv = negimg_arr cdef unsigned char[::1] mask_mv = mask_arr cdef int[::1] output_mv = output_arr cdef Py_ssize_t[::1] n_labels_mv = n_labels_arr cdef double* posimg = &posimg_mv[0] cdef double* negimg = &negimg_mv[0] cdef unsigned char* mask = &mask_mv[0] cdef int* output = &output_mv[0] cdef const data_t* data_ptr = &data[0, 0] cdef const segm_t* segm_ptr = &segm_data[0, 0] with nogil: for isrc in range(n_src): if n_markers[isrc] < 2: continue ny_c = y1[isrc] - y0[isrc] nx_c = x1[isrc] - x0[isrc] n_tot = ny_c * nx_c start = starts[isrc] n_max_labels = 0 for iy in range(ny_c): for ix in range(nx_c): idx = (y0[isrc] + iy) * img_nx + x0[isrc] + ix p = iy * nx_c + ix posimg[p] = data_ptr[idx] if isnan(posimg[p]): # NaN pixels are flooded after all finite # pixels and add nothing to the basin fluxes negimg[p] = INFINITY posimg[p] = 0.0 else: negimg[p] = -posimg[p] mask[p] = segm_ptr[idx] == labels[isrc] output[p] = packed[start + p] if output[p] > n_max_labels: n_max_labels = output[p] status = _contrast_core(posimg, negimg, mask, output, ny_c, nx_c, conn8, contrast, source_sum[isrc], source_min[isrc], n_max_labels, apply_contrast) if status < 0: failed = isrc break n_labels_mv[isrc] = status if status >= 2: for p in range(n_tot): packed[start + p] = output[p] else: for p in range(n_tot): packed[start + p] = 0 if failed >= 0: if status == -1: raise MemoryError msg = (f'Deblending failed for source {int(labels[failed])!r}. ' 'Please ensure you used the same pixel connectivity ' 'in detect_sources and deblend_sources.') raise ValueError(msg) return n_labels_arr def write_deblended_labels(segm_t[:, ::1] segm_out, const int[::1] packed, const Py_ssize_t[::1] starts, const long long[::1] y0, const long long[::1] y1, const long long[::1] x0, const long long[::1] x1, const Py_ssize_t[::1] n_labels, const long long[::1] label_offsets): """ Write the deblended labels of a chunk into a segmentation array. For every source with two or more final labels, the nonzero labels of its packed region are written into ``segm_out`` at its bounding box, offset by ``label_offsets``. The other sources are left untouched. Parameters ---------- segm_out : 2D int `~numpy.ndarray` The segmentation array to write into. It must be a copy of the input segmentation array. packed : 1D int32 `~numpy.ndarray` The packed label buffer written by ``deblend_contrast_chunk``. starts : 1D intp `~numpy.ndarray` The start index of each source's region in ``packed``. y0, y1, x0, x1 : 1D int64 `~numpy.ndarray` The bounding-box slice bounds of each source. n_labels : 1D intp `~numpy.ndarray` The number of final labels of each source. label_offsets : 1D int64 `~numpy.ndarray` The value added to the labels of each source. Raises ------ ValueError If the per-source arrays do not have one entry per source, or if ``packed`` is too small for the source regions. """ cdef Py_ssize_t n_src = n_labels.shape[0] cdef Py_ssize_t img_nx = segm_out.shape[1] cdef Py_ssize_t isrc, ny_c, nx_c, iy, ix, idx, p, start cdef int value cdef segm_t* out_ptr = &segm_out[0, 0] if (y0.shape[0] != n_src or y1.shape[0] != n_src or x0.shape[0] != n_src or x1.shape[0] != n_src or starts.shape[0] != n_src or label_offsets.shape[0] != n_src): msg = 'every per-source array must have one entry per source' raise ValueError(msg) for isrc in range(n_src): if (starts[isrc] + (y1[isrc] - y0[isrc]) * (x1[isrc] - x0[isrc]) > packed.shape[0]): msg = 'packed is too small for the source regions' raise ValueError(msg) with nogil: for isrc in range(n_src): if n_labels[isrc] < 2: continue ny_c = y1[isrc] - y0[isrc] nx_c = x1[isrc] - x0[isrc] start = starts[isrc] for iy in range(ny_c): for ix in range(nx_c): p = iy * nx_c + ix value = packed[start + p] if value != 0: idx = (y0[isrc] + iy) * img_nx + x0[isrc] + ix out_ptr[idx] = (value + label_offsets[isrc]) def find_present_labels(const segm_t[:, ::1] segm, unsigned char[::1] present): """ Mark the label values that are present in a segmentation array. The array is scanned once without the GIL. Disjoint row bands of an image can be scanned concurrently, each with its own ``present`` array, and the results combined with a logical OR. Parameters ---------- segm : 2D int `~numpy.ndarray` The segmentation array (or a band of its rows). Every value must be in the range ``[0, len(present) - 1]``. present : 1D uint8 `~numpy.ndarray` The array, indexed by label value, in which the element of every value found in ``segm`` is set to 1. The other elements are left unchanged. Raises ------ ValueError If a value in ``segm`` is negative or is too large to index ``present``. """ cdef Py_ssize_t n_pix = segm.shape[0] * segm.shape[1] cdef Py_ssize_t n_present = present.shape[0] cdef Py_ssize_t i cdef long long value cdef bint invalid = False cdef const segm_t* segm_ptr cdef unsigned char* present_ptr if n_pix == 0: return if n_present == 0: msg = 'present must not be empty' raise ValueError(msg) segm_ptr = &segm[0, 0] present_ptr = &present[0] with nogil: for i in range(n_pix): value = segm_ptr[i] if value < 0 or value >= n_present: invalid = True break present_ptr[value] = 1 if invalid: msg = 'segm contains a value outside the range of present' raise ValueError(msg) def apply_relabel_map(segm_t[:, ::1] segm, const long long[::1] relabel_map): """ Replace every value of a segmentation array, in place, by its entry in a relabeling map. The array is rewritten once without the GIL. Disjoint row bands of an image can be rewritten concurrently. Parameters ---------- segm : 2D int `~numpy.ndarray` The segmentation array (or a band of its rows) to relabel in place. Every value must be in the range ``[0, len(relabel_map) - 1]``. relabel_map : 1D int64 `~numpy.ndarray` The array mapping the original label values to the new label values. Raises ------ ValueError If a value in ``segm`` is negative or is too large to index ``relabel_map``. The values before the invalid one have then already been relabeled. """ cdef Py_ssize_t n_pix = segm.shape[0] * segm.shape[1] cdef Py_ssize_t n_map = relabel_map.shape[0] cdef Py_ssize_t i cdef long long value cdef bint invalid = False cdef segm_t* segm_ptr cdef const long long* map_ptr if n_pix == 0: return if n_map == 0: msg = 'relabel_map must not be empty' raise ValueError(msg) segm_ptr = &segm[0, 0] map_ptr = &relabel_map[0] with nogil: for i in range(n_pix): value = segm_ptr[i] if value < 0 or value >= n_map: invalid = True break segm_ptr[i] = map_ptr[value] if invalid: msg = 'segm contains a value outside the range of relabel_map' raise ValueError(msg) astropy-photutils-24be529/photutils/segmentation/_label_stats.pyx000066400000000000000000000104331526452531300255370ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True # cython: freethreading_compatible=True """ Cython kernel that measures the labels of a segmentation array. The pixel count and the bounding box of every label are accumulated in one pass over the array. Each row is walked as runs of equal values, so the per-label arrays are updated once per run instead of once per pixel. The per-label arrays are indexed by label value, so their size is set by the largest label and not by the number of labels. The caller gives an upper limit of the largest label that it accepts, and falls back to a sort-based method for arrays with larger labels. The kernel runs without the GIL and uses no global mutable state, so this module is safe to use from multiple threads, including on free-threaded Python builds. """ import numpy as np __all__ = ['label_stats'] ctypedef fused segm_t: int long long def label_stats(const segm_t[:, ::1] segm, long long max_label_limit): """ Calculate the pixel count and bounding box of every label in a segmentation array. Parameters ---------- segm : 2D int32 or int64 `~numpy.ndarray` The C-contiguous segmentation array. A value of zero is the background. max_label_limit : int The largest label value for which the per-label arrays are allocated. Returns ------- min_value, max_value : int The minimum and maximum values in ``segm``. Both are zero for an array of zero size. counts : 1D intp `~numpy.ndarray` or `None` The number of pixels of each label value, indexed by the label value. The background element is zero. `None` if ``segm`` has zero size, a negative value, or a value larger than ``max_label_limit``. The label arrays below are then also `None`. ymin, ymax, xmin, xmax : 1D intp `~numpy.ndarray` or `None` The inclusive bounds of the minimal bounding box of each label value, indexed by the label value. The elements of the values that are not present are undefined. """ cdef Py_ssize_t ny = segm.shape[0] cdef Py_ssize_t nx = segm.shape[1] cdef Py_ssize_t n_pix = ny * nx cdef Py_ssize_t i, y, x, x0, n_values cdef segm_t value, vmin, vmax cdef const segm_t* segm_ptr cdef const segm_t* row if n_pix == 0: return 0, 0, None, None, None, None, None segm_ptr = &segm[0, 0] vmin = segm_ptr[0] vmax = segm_ptr[0] with nogil: for i in range(n_pix): value = segm_ptr[i] if value < vmin: vmin = value if value > vmax: vmax = value if vmin < 0 or vmax > max_label_limit: return int(vmin), int(vmax), None, None, None, None, None # The cast avoids an overflow of the 32-bit label type n_values = vmax + 1 counts_arr = np.zeros(n_values, dtype=np.intp) ymin_arr = np.empty(n_values, dtype=np.intp) ymax_arr = np.empty(n_values, dtype=np.intp) xmin_arr = np.full(n_values, nx, dtype=np.intp) xmax_arr = np.full(n_values, -1, dtype=np.intp) cdef Py_ssize_t[::1] counts = counts_arr cdef Py_ssize_t[::1] ymin = ymin_arr cdef Py_ssize_t[::1] ymax = ymax_arr cdef Py_ssize_t[::1] xmin = xmin_arr cdef Py_ssize_t[::1] xmax = xmax_arr with nogil: for y in range(ny): row = segm_ptr + y * nx x = 0 while x < nx: value = row[x] if value == 0: x += 1 continue # A run of equal labels x0 = x x += 1 while x < nx and row[x] == value: x += 1 # The rows are visited in increasing order, so the # first run of a label is on its first row if counts[value] == 0: ymin[value] = y counts[value] += x - x0 ymax[value] = y if x0 < xmin[value]: xmin[value] = x0 if x - 1 > xmax[value]: xmax[value] = x - 1 return (int(vmin), int(vmax), counts_arr, ymin_arr, ymax_arr, xmin_arr, xmax_arr) astropy-photutils-24be529/photutils/segmentation/catalog.py000066400000000000000000007273231526452531300243420ustar00rootroot00000000000000# 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 warnings from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from functools import cached_property import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from astropy.stats import gaussian_fwhm_to_sigma from scipy.ndimage import map_coordinates from photutils._future_column_names import get_future_column_names from photutils.aperture import (BoundingBox, CircularAperture, EllipticalAperture, RectangularAnnulus) from photutils.aperture._batch_photometry import (FLAG_COL_BBOX_CLIPPED, FLAG_COL_MASKED, FLAG_COL_N_PIXELS, FLAG_COL_NONFINITE_DATA, FLAG_COL_NONFINITE_ERROR, FLAG_COL_SEG, FLAG_COL_SEG_MASKED, FLAG_COL_UNCORRECTED, FLAG_COL_UNCORRECTED_MASKED, FLAG_COL_VALID, SHAPE_CIRCLE, SHAPE_ELLIPSE, batch_aperture_sums) from photutils.aperture._batch_results import BatchApertureSums from photutils.aperture._common import batch_image_dtype from photutils.aperture._segmentation import (SEG_METHOD_CODES, batch_segmentation_image) from photutils.segmentation._batch_catalog import (batch_central_moments, batch_centroid_win, batch_flux_radius_prepare, batch_flux_radius_solve, batch_kron_radius, batch_local_background, batch_minmax_index, batch_moment_err, batch_perimeter, batch_quad_boxes, batch_raw_moments, batch_segment_gather) from photutils.segmentation._batch_results import BatchFluxRadiusArgs from photutils.segmentation.core import SegmentationImage from photutils.segmentation.flags import (SEGMENTATION_FLAGS, decode_segmentation_flags) from photutils.utils._deprecation import (create_empty_deprecated_qtable, deprecated, deprecated_getattr, deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils._flags import update_flag_docstring from photutils.utils._misc import _get_meta from photutils.utils._moments import (PIXEL_VARIANCE, centroid_from_moments, covariance_determinant, covariance_from_moments, eigvals_from_covariance, floor_covariance_eigvals, inertia_tensor_from_moments, is_invalid_covariance, is_singular_covariance, orientation_from_covariance, pixel_to_sky_covariance, regularize_covariance, sky_orientation_from_covariance) from photutils.utils._parameters import validate_table_columns from photutils.utils._quantity_helpers import process_quantities from photutils.utils.cutouts import CutoutImage from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = ['SourceCatalog'] # Default table columns for the 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', 'flags') # The inner rectangle of the local background annulus is this factor # times the segment bounding box size _LOCAL_BKG_SCALE = 1.5 # The minimum number of usable local background annulus pixels. The # local background of a source with fewer pixels is zero. _LOCAL_BKG_MIN_PIXELS = 10 class _SegmentValues: """ The unmasked pixel values within the segments of many sources, packed into one array. This is the container form of the ``batch_segment_gather`` outputs. Indexing it with an integer, slice, index array, or boolean mask returns a new container holding the selected sources (an integer index gives a one-source container, never a bare array), which lets `SourceCatalog.__getitem__` slice the cached values along the source axis. Iterating over it yields the 1D array of each source. Parameters ---------- packed : 1D `~numpy.ndarray` The packed values of all sources in row-major pixel order, with a single NaN placeholder for a source with no unmasked pixels. offsets : 1D `~numpy.ndarray` The start offset of each source in ``packed``, with length ``n_sources + 1``. counts : 1D `~numpy.ndarray` The number of unmasked pixels of each source (zero for a completely masked source, which occupies one placeholder slot). """ __slots__ = ('counts', 'offsets', 'packed') def __init__(self, packed, offsets, counts): self.packed = packed self.offsets = offsets self.counts = counts def __len__(self): return len(self.counts) def __iter__(self): return iter(self.values) def __getitem__(self, index): idx = np.atleast_1d(np.arange(len(self))[index]) sizes = self.sizes[idx] offsets = np.zeros(len(idx) + 1, dtype=np.intp) np.cumsum(sizes, out=offsets[1:]) # The position of each selected value in the parent packed array pos = (np.repeat(self.offsets[idx] - offsets[:-1], sizes) + np.arange(offsets[-1])) return _SegmentValues(self.packed[pos], offsets, self.counts[idx]) @property def sizes(self): """ The number of packed slots of each source (one for a completely masked source). """ return np.diff(self.offsets) @property def values(self): """ A list of the 1D value arrays of each source (a single NaN for a completely masked source). """ return np.split(self.packed, self.offsets[1:-1]) def reduce(self, ufunc, *, transform=None): """ Reduce the values of each source with a ufunc. Parameters ---------- 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 packed values before reducing (e.g., `~numpy.square`). Returns ------- result : 1D `~numpy.ndarray` The per-source reduction (NaN for a completely masked source). """ packed = self.packed if transform is not None: packed = transform(packed) return ufunc.reduceat(packed, self.offsets[:-1]) def _concatenate_aperture_sums(results): """ Concatenate per-chunk ``batch_aperture_sums`` results along the source axis. Only the per-source fields are combined. The fields of the packed per-pixel output (``starts``, ``sum_values``, ``sum_fracs``, ``sum_errsq``, and ``sum_counts``), which hold no data unless the driver is called with ``emit_sum``, are not merged and are returned as `None`. Parameters ---------- results : list of `~photutils.aperture._batch_results.BatchApertureSums` The per-chunk driver results, in source order. Returns ------- result : `~photutils.aperture._batch_results.BatchApertureSums` The combined per-source results. """ packed = ('starts', 'sum_values', 'sum_fracs', 'sum_errsq', 'sum_counts') fields = {name: np.concatenate([getattr(result, name) for result in results]) for name in BatchApertureSums._fields if name not in packed} return BatchApertureSums(**fields, **dict.fromkeys(packed)) def _concatenate_arrays(results): """ Concatenate per-chunk tuples of per-source arrays, element by element, along the source axis. Parameters ---------- results : list of tuple of `~numpy.ndarray` The per-chunk kernel outputs, in source order. Returns ------- result : tuple of `~numpy.ndarray` The combined per-source arrays. """ return tuple(np.concatenate(parts) for parts in zip(*results, strict=True)) def _concatenate_segment_gathers(results): """ Concatenate per-chunk ``batch_segment_gather`` results along the source axis. The packed buffers are concatenated and the per-source offsets (which have one more entry than the sources of a chunk) are rebased to the combined buffer. Parameters ---------- results : list of tuple The per-chunk ``(packed, offsets, counts)`` outputs, in source order. Returns ------- packed, offsets, counts : tuple of `~numpy.ndarray` The combined outputs. """ packed = np.concatenate([result[0] for result in results]) counts = np.concatenate([result[2] for result in results]) offsets = np.empty(len(counts) + 1, dtype=np.intp) start = 0 total = 0 for _packed, chunk_offsets, chunk_counts in results: n_chunk = len(chunk_counts) offsets[start:start + n_chunk] = chunk_offsets[:-1] + total start += n_chunk total += chunk_offsets[-1] offsets[-1] = total return packed, offsets, counts def _concatenate_flux_radius_args(results): """ Concatenate per-chunk ``batch_flux_radius_prepare`` results along the source axis. The packed ``values`` buffers are concatenated and the per-source ``starts`` are rebased to the combined buffer. Parameters ---------- results : list of `BatchFluxRadiusArgs` The per-chunk driver results, in source order. Returns ------- result : `~photutils.segmentation._batch_results.BatchFluxRadiusArgs` The combined per-source inputs. """ offsets = np.cumsum([0] + [len(result.values) for result in results]) starts = np.concatenate([result.starts + offset for result, offset in zip(results, offsets[:-1], strict=True)]) scalars = ('use_exact', 'subpixels') fields = {name: np.concatenate([getattr(result, name) for result in results]) for name in BatchFluxRadiusArgs._fields if name not in ('starts', *scalars)} return BatchFluxRadiusArgs(**fields, starts=starts, **{name: getattr(results[0], name) for name in scalars}) def _batch_gini(values): """ Compute the Gini coefficient of the segment pixel values of many sources. This is the vectorized form of `photutils.morphology.gini` applied to each source's unmasked (finite) absolute pixel values. The result is NaN for a source with no pixels, 0.0 for a single pixel or a zero mean, and otherwise the Lotz et al. (2004) sum over the sorted values. The values of each source are sorted with NumPy in a Python loop rather than with the compiled ``batch_gini`` kernel used by `~photutils.aperture.ApertureStats`. NumPy's vectorized sort beats the kernel's scalar introsort for sources of more than ~50 pixels. The kernel is faster only for very small sources, where the per-source Python overhead of this loop dominates. Parameters ---------- values : `_SegmentValues` The packed segment pixel values of the sources. Returns ------- result : 1D `~numpy.ndarray` The Gini coefficient of each source. """ packed = values.packed offsets = values.offsets counts = values.counts sizes = values.sizes starts = offsets[:-1] # Sort the absolute values within each source in place (a single # sort per source is much cheaper than one lexsort over all of the # values). The placeholder values of empty sources are harmless # because their results are set to NaN below. values = np.abs(np.nan_to_num(packed)) for start, stop in zip(starts, offsets[1:], strict=True): values[start:stop].sort() # 1-based rank of each value within its source rank = np.arange(len(values)) - np.repeat(starts, sizes) + 1 n_pixels = np.repeat(counts, sizes) kernel = (2.0 * rank - n_pixels - 1) * values # Ignore RuntimeWarning from the empty and single-pixel sources with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) mean = np.add.reduceat(values, starts) / counts normalization = mean * counts * (counts - 1) result = np.add.reduceat(kernel, starts) / normalization result[normalization == 0.0] = 0.0 result[counts == 1] = 0.0 result[counts == 0] = np.nan return result # 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', } # Public nonunderscore attributes that must never be collapsed to a # scalar for a single-source (scalar) catalog (see __getattribute__). _SCALAR_EXCLUDE = frozenset({'isscalar', 'n_labels', 'labels', 'properties', 'custom_properties', 'default_columns', 'kron_params'}) def use_detcat(method): """ Decorate a method so that it returns the value from the detection catalog, if one was input, instead of calculating 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 def _update_flags_docstring(func): """ Decorator to insert the segmentation flag descriptions into a docstring. The ```` placeholder in the function docstring is replaced with a bullet list generated from ``SEGMENTATION_FLAGS``. Parameters ---------- func : function The function to decorate. Returns ------- func : function The decorated function with an updated docstring. """ return update_flag_docstring(func, SEGMENTATION_FLAGS, indent=None) 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` 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. Nonfinite ``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. ``convolved_data`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array then ``convolved_data`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Nonfinite ``convolved_data`` values (NaN and inf) are not automatically masked, unless they are at the same position of nonfinite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. A pixel with a nonfinite ``data`` value and a finite ``convolved_data`` value (e.g., a NaN value that :func:`~astropy.convolution.convolve` interpolated over) is included in the isophotal centroid and morphological properties with its ``convolved_data`` value, unlike a pixel masked with the ``mask`` keyword. It does not contribute to the errors of the isophotal centroid. To exclude such pixels from these properties, include them in the input ``mask`` (e.g., ``mask=~np.isfinite(data)``). 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. Nonfinite ``error`` values (NaN and inf) are not automatically masked, unless they are at the same position of nonfinite 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. Nonfinite values (NaN and inf) in the input ``data`` are automatically masked. background : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The background level that was *previously* present in the input ``data``. ``background`` must be 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. Inputting the ``background`` merely allows for its properties to be measured within each source segment. The input ``background`` does *not* get subtracted from the input ``data``, which should already be background-subtracted. Nonfinite ``background`` values (NaN and inf) are not automatically masked, unless they are at the same position of nonfinite 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 windowed centroids, and `flux_radius`. The options are: * 'correct': replace pixels assigned to neighboring sources with the 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 : 1D sequence of 2 or 3 floats, optional The 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`. The aperture fluxes and `flux_radius` are measured in the input ``data`` using the detection catalog centroids and Kron apertures. `flux_radius` is the radius that encloses the given fraction of the Kron flux measured in the input ``data``, so it is not the ``flux_radius`` of the detection catalog. The windowed centroids are those of the detection catalog, which uses its own half-light radius to compute them. n_threads : int, optional The number of threads to use for the compiled per-source measurements: the isophotal moments and their errors, the segment pixel statistics (e.g., `segment_flux`), the windowed and quadratic centroids, `perimeter`, the local background, the Kron radii and photometry, `circular_photometry`, and `flux_radius`, and thus every property derived from them. The default is 1 (no multithreading). When ``n_threads`` > 1, the sources are divided into chunks that are processed concurrently. The per-source results are independent, so they are identical to the single-threaded computation. The underlying kernels release the Python global interpreter lock (GIL), so multithreading can significantly speed up these measurements for many sources. progress_bar : bool, optional Whether to display a progress bar when calculating properties. .. deprecated:: 3.1 This keyword argument is deprecated and will be removed in version 4.0. All property calculations are now computed for all sources at once in compiled code, so no progress bar is displayed and this keyword has no effect. 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. Note that `~photutils.aperture.ApertureStats` does not set negative values to zero, because an aperture on background-subtracted data contains many negative sky pixels whose removal would bias the centroid and the shape properties. Most properties are computed for all sources at once in compiled code that reads the ``data``, ``error``, ``convolved_data``, and ``background`` arrays, the segmentation array, and a uint8 mask plane. C-contiguous, native byte-order image arrays that are all float32 or all float64 are read directly, as is a C-contiguous, native byte-order int32 or intp segmentation array, so no copies of them are made. Image arrays that are not all float32 or all float64 (e.g., float32 ``data`` with a float64 ``error`` array, or integer arrays) are converted, where needed, to working copies in a common dtype, which is float64 unless every image array is a floating-point array of at most 4 bytes per value. A segmentation array with another integer dtype is converted to intp. Any working copies are built on first use and kept for the lifetime of the catalog (they are shared with sliced catalogs). All calculations are performed in float64 regardless of the input dtype, so float32 inputs give the same results as the same values input as float64 (to within floating-point rounding). If memory is a concern, input all of the image arrays as C-contiguous, native byte-order arrays with the same float32 or float64 dtype. Otherwise, call `~photutils.segmentation.SourceCatalog.release_cache` after calculating the desired properties to free the working copies. 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. For the aperture-based fluxes (e.g., `~photutils.segmentation.SourceCatalog.kron_flux` and `~photutils.segmentation.SourceCatalog.circular_photometry`), the flux is the sum of the pixel values weighted by their aperture overlap fractions. The flux error therefore weights each pixel variance by the squared overlap fraction: :math:`\\Delta F = \\sqrt{\\sum_{i \\in A} w_i^2 \\sigma_{\\mathrm{tot}, i}^2}`, where :math:`w_i` are the aperture overlap fractions and :math:`A` are the unmasked pixels within the aperture. 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 nonfinite ``data`` values (NaN and inf). **Scalar vs. Multisource Catalogs** A `SourceCatalog` can represent a single source or multiple sources. Most properties adapt their return type accordingly. For a multisource 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 multisource catalog, but a single aperture object for a scalar catalog. Similarly, `data_cutout` returns a list of 2D cutout arrays for a multisource catalog, but a single 2D array for a scalar catalog. A scalar catalog is created when a multisource catalog is indexed to select a single source. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ """ # Cached properties whose values cannot be sliced per source # (buffers packed across sources or tuples of per-source arrays). # __getitem__ drops them from the sliced catalog, which recomputes # them on demand. _recompute_on_slice = ('_flux_radius_optimizer_args', '_local_background_batch') @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') @deprecated_renamed_argument('progress_bar', None, '3.1', 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, n_threads=1, 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 if (isinstance(n_threads, bool) or not isinstance(n_threads, (int, np.integer)) or n_threads < 1): msg = 'n_threads must be a positive integer' raise ValueError(msg) self.n_threads = int(n_threads) # Needed for ordering and isscalar # NOTE: calculate slices before labels for performance. # _labels is initially always a nonscalar 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 nonzero 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 = list(_DEFAULT_COLUMNS) self._custom_properties = [] self._flux_radius_cache = {} # The full-image arrays used by the batch Cython drivers, filled # lazily by _get_batch_arrays. The dict is shared by reference # with sliced catalogs (see __getitem__), so whichever catalog # builds the arrays first populates it for all of them. self._batch_arrays_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, so 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) if name == 'mask': # A nonboolean mask would be used as a fancy index array = array.astype(bool, copy=False) 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) n_params = len(kron_params) if n_params 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 n_params == 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 built-in source properties. """ cached_properties = [name for name in self._cached_properties if not name.startswith('_')] cached_properties.remove('isscalar') cached_properties.remove('n_labels') cached_properties.extend(['label', 'labels', 'slices']) cached_properties.sort() return cached_properties @property def _cached_properties(self): """ A list of all class cached properties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_properties_cache' # Subclasses get their own cached-property list if attr not in cls.__dict__: def is_cached_property(obj): return isinstance(obj, cached_property) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=is_cached_property)]) 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', 'n_threads', 'progress_bar', '_batch_arrays_cache') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # Copy mutable containers so that mutating the sliced catalog # (e.g., via add_property) does not also mutate the parent # catalog (and vice versa). newcls.default_columns = self.default_columns.copy() newcls._custom_properties = self._custom_properties.copy() newcls.meta = self.meta.copy() newcls._aperture_mask_kwargs = { key: value.copy() for key, value in self._aperture_mask_kwargs.items()} # _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]) # _slices is always stored as a list (one tuple of slices per # source), even for a scalar catalog. The public ``slices`` # property is collapsed by __getattribute__. attr = '_slices' value = self._index_object_list(getattr(self, attr), index) if newcls.isscalar: value = [value] setattr(newcls, attr, value) # Slice the flux_radius cache values. Like the other private # per-source attributes, the cached arrays keep a length-1 # leading axis for a scalar catalog. The cache and the instance # dict below are copied in one step before they are iterated, # because another thread may add to them at the same time. cache_index = [index] if newcls.isscalar else index newcls._flux_radius_cache = { key: value[cache_index] for key, value in dict(self._flux_radius_cache).items()} # Evaluated cached-property objects and extra properties keys = (set(self.__dict__) & (set(self._cached_properties) | set(self._custom_properties))) # The packed caches are recomputed by the sliced catalog on # demand (its flux_radius cache is sliced above). keys.difference_update(self._recompute_on_slice) 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 private ('_'-prefixed) cached properties as length-1 # iterables. Such properties are never collapsed by # __getattribute__ and internal code relies on them # always being iterable. if isinstance(value, _SegmentValues): # Always a container of at least one source val = value[index] elif 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') def __getattribute__(self, name): # Centralized scalar collapse. For a scalar (single-source) # catalog, public properties that return a length-1 # array/list/tuple (or a scalar SkyCoord) are returned as a # scalar value. Private ('_'-prefixed) attributes are always # returned unchanged so that internal code can rely on iterable # values. value = super().__getattribute__(name) if (not name.startswith('_') and name not in _SCALAR_EXCLUDE and isinstance(value, (np.ndarray, SkyCoord, list, tuple)) and self.isscalar): try: if len(value) == 1: return value[0] except TypeError: # value has no len pass return value def _make_scalar(self, value): """ Return a scalar value for a scalar catalog if ``value`` is a length-1 sequence, otherwise return ``value`` unchanged. This is the method-call analog of the scalar collapse performed for attribute access by `__getattribute__`. Most callers pass a sequence with one entry per source. The plotting methods pass only the patches of the apertures that are not `None`. """ if self.isscalar and len(value) == 1: return value[0] return value def _array(self, name): """ Return the per-source attribute ``name`` with a leading (source) axis, so that internal code can always index along the source axis without repeating ``if self.isscalar`` checks. For a nonscalar (multisource) catalog this simply returns the attribute unchanged. For a scalar (single-source) catalog the value is first read through the normal attribute access, which applies the scalar collapse performed by `__getattribute__` (e.g., ``moments`` becomes a ``(4, 4)`` array and ``centroid`` a length-2 array). A single leading length-1 axis is then restored (``ndarray`` values are reshaped, while other objects are wrapped in a length-1 list). Reading the collapsed value rather than the raw stored value is important. A per-source array can be stored either with its leading length-1 axis intact (when computed directly for a scalar catalog) or with that axis already removed (when a multisource value is cached and then sliced to a scalar). Going through the scalar collapse normalizes both cases to the same shape, so this method is robust regardless of how the value was produced. """ value = getattr(self, name) if not self.isscalar: return value if isinstance(value, np.ndarray): return value[np.newaxis, ...] return [value] def _get_batch_arrays(self): """ Return the cached C-contiguous full-image arrays used by the batch Cython drivers. The dict holds the ``data`` and ``error`` arrays in the common image dtype (float32 or float64, see `batch_image_dtype`), a uint8 ``mask`` plane (bit 1 = input mask, bit 2 = nonfinite data), and an int32 or intp ``segm`` array (see `batch_segmentation_image`). Inputs already in the required form are stored without a copy. The dict itself is created in ``__init__`` and shared by reference with sliced catalogs (see ``__getitem__``), so the arrays are built once no matter which of the parent or sliced catalogs first needs them. Returns ------- result : dict A dict with keys ``'data'``, ``'error'``, ``'mask'``, ``'segm'``, and ``'dtype'`` (the common image dtype). The ``'error'`` value is `None` if no error array was input. The ``'convdata'`` and ``'background'`` keys are added lazily by ``_get_batch_convdata`` and ``_get_batch_background``. """ arrays = self._batch_arrays_cache if 'data' not in arrays: # The kernels need all of the images in a common dtype. dtype = batch_image_dtype(self._data, self._error, self._convolved_data, self._background) data = np.ascontiguousarray(self._data, dtype=dtype) error = None if self._error is not None: error = np.ascontiguousarray(self._error, dtype=dtype) mask_plane = np.zeros(data.shape, dtype=np.uint8) if self._mask is not None: mask_plane[self._mask] |= 1 mask_plane[~np.isfinite(data)] |= 2 segm = batch_segmentation_image(self._segmentation_image.data) arrays.update(data=data, error=error, mask=mask_plane, segm=segm, dtype=dtype) return arrays def _get_batch_convdata(self): """ Return the cached C-contiguous convolved-data array, in the common image dtype, used by the batch moment kernels. """ arrays = self._get_batch_arrays() if 'convdata' not in arrays: if self._convolved_data is self._data: arrays['convdata'] = arrays['data'] else: arrays['convdata'] = np.ascontiguousarray( self._convolved_data, dtype=arrays['dtype']) return arrays['convdata'] def _get_batch_background(self): """ Return the cached C-contiguous background array, in the common image dtype, used by the batch segment gather. """ arrays = self._get_batch_arrays() if 'background' not in arrays: arrays['background'] = np.ascontiguousarray( self._background, dtype=arrays['dtype']) return arrays['background'] def _batch_labels(self): """ Return the source labels as the C-contiguous intp array used by the batch Cython drivers. """ return np.ascontiguousarray(np.atleast_1d(self.labels), dtype=np.intp) @cached_property def _batch_bboxes(self): """ The per-source segment bounding boxes as an ``(n_labels, 4)`` intp array with columns ``(iymin, iymax, ixmin, ixmax)``, where the maxima are exclusive. A 2D array (rather than a tuple of arrays) so that the cached value is sliced along the source axis by ``__getitem__``. """ bboxes = np.empty((len(self._slices), 4), dtype=np.intp) for i, (slc_y, slc_x) in enumerate(self._slices): bboxes[i] = (slc_y.start, slc_y.stop, slc_x.start, slc_x.stop) return bboxes def _get_batch_bboxes(self): """ Return the per-source segment bounding boxes as ``(iymin, iymax, ixmin, ixmax)`` C-contiguous intp arrays. """ return tuple(np.ascontiguousarray(col) for col in self._batch_bboxes.T) def _batch_bbox_args(self): """ The per-source labels and segment bounding boxes as the ``per_source`` dict of `_threaded_batch` for the kernels that walk each source's bounding box. """ iymin, iymax, ixmin, ixmax = self._get_batch_bboxes() return {'labels': self._batch_labels(), 'bbox_iymin': iymin, 'bbox_iymax': iymax, 'bbox_ixmin': ixmin, 'bbox_ixmax': ixmax} def _threaded_batch(self, func, per_source, *, packed=None, merge=np.concatenate, **fixed): """ Call a per-source batch kernel over contiguous chunks of sources, concurrently when ``n_threads`` > 1. The kernel is called as ``func(**per_source, **packed, **fixed)``. When ``n_threads`` > 1, the sources are divided into ``min(n_threads, n_sources)`` contiguous ranges. Each worker receives the corresponding row slices of the ``per_source`` arrays (row slices of C-contiguous arrays are themselves C-contiguous), the corresponding regions of the ``packed`` buffers, and the ``fixed`` arguments (e.g., the full-image arrays and scalar options) unchanged, so the kernels run concurrently on disjoint sources. The per-chunk outputs are combined with ``merge``, so the result is identical to the single-chunk call. Parameters ---------- func : callable The batch kernel. It must release the GIL. per_source : dict The per-source arrays (one row per source), keyed by the kernel keyword name. A `None` value is passed unchanged. packed : dict, optional Packed per-pixel buffers, keyed by the kernel keyword name. Each source occupies the region given by the ``'starts'`` and ``'counts'`` entries of ``per_source``, which must then be present. The chunk ``starts`` are rebased to the chunk buffer region. merge : callable, optional The function that combines the list of per-chunk outputs. The default concatenates arrays along the source axis. **fixed The remaining kernel keyword arguments, passed unchanged to every chunk. Returns ------- result : object The (merged) kernel output. """ packed = {} if packed is None else packed n_src = next(len(arr) for arr in per_source.values() if arr is not None) n_chunks = min(self.n_threads, n_src) if n_chunks <= 1: # 0 for no sources return func(**per_source, **packed, **fixed) edges = np.arange(n_chunks + 1) * n_src // n_chunks if packed: starts = per_source['starts'] counts = per_source['counts'] buffer_ends = starts + counts def run_chunk(i0, i1): chunk = {key: None if arr is None else arr[i0:i1] for key, arr in per_source.items()} if packed: # The chunk region spans from the first source start to # the last source end (a skipped source may have a zero # count). buf0 = starts[i0] buf1 = buffer_ends[i0:i1].max(initial=buf0) chunk['starts'] = chunk['starts'] - buf0 chunk.update({key: buffer[buf0:buf1] for key, buffer in packed.items()}) return func(**chunk, **fixed) with ThreadPoolExecutor(max_workers=n_chunks) as executor: results = list(executor.map(run_chunk, edges[:-1], edges[1:])) return merge(results) @cached_property def isscalar(self): """ Whether the instance is scalar (i.e., a single source selected by indexing a catalog). """ 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) def release_cache(self): """ Release the cached full-image working arrays. The compiled routines that calculate the source properties read the input ``data``, ``error``, ``background``, and ``convolved_data`` arrays and the segmentation image directly when they are C-contiguous with native byte order, the image arrays are all ``float32`` or all ``float64``, and the segmentation array is ``int32`` or `numpy.intp`. Otherwise, working copies are created the first time they are needed and are then cached for the lifetime of the catalog, so that they are shared by all of the source properties. A 1 byte per pixel mask array is always cached. Call this method after calculating the desired properties to free that memory. Source properties that were already calculated are unaffected. The working arrays are recreated on demand if another property is later requested. The cache is shared with catalogs created by slicing this catalog, so it is also released for them. A catalog input as the ``detection_catalog`` has its own cache, which is not released by this method. Notes ----- No copies are needed, and only the mask array is cached, when the conditions above are met. The image arrays are converted to ``float64`` copies, which need 8 bytes per pixel each, when their dtypes differ from each other (e.g., ``float32`` data with a ``float64`` error array) or when any of them has another dtype (e.g., an integer dtype). For example, a 4096 x 4096 ``float32`` image input with ``float64`` ``error`` and ``float32`` ``convolved_data`` arrays caches about 285 MB. Big-endian arrays (e.g., data read from a FITS file) and ``float16`` arrays are converted to native copies of the common dtype, which need 4 bytes per pixel for ``float32`` and 8 bytes per pixel for ``float64``. Calling this method is worthwhile when all of the following are true: * The image is large and working copies are needed (see above), so the cache is a significant amount of memory. * All of the desired properties have been calculated, e.g., after calling `~photutils.segmentation.SourceCatalog.to_table` or after accessing the last property that is needed. * The catalog object stays alive while other memory-intensive work is performed. The cache is freed along with the catalog when the catalog is deleted or goes out of scope, so there is no need to call this method on a catalog that is about to be discarded. Avoid calling this method between property calculations. The working arrays are then recreated for the next property, which costs a full-image copy of each input and does not lower the peak memory. The simplest way to avoid the working copies is to input all of the image arrays as C-contiguous arrays with the same ``float32`` or ``float64`` dtype. The results do not depend on the input dtype (to within floating-point rounding), because all calculations are performed in ``float64``. Examples -------- >>> from photutils.datasets import make_4gaussians_image >>> from photutils.segmentation import (SourceCatalog, ... detect_sources) >>> data = make_4gaussians_image().astype('float32') >>> segment_img = detect_sources(data, 50.0, n_pixels=10) >>> cat = SourceCatalog(data, segment_img) >>> tbl = cat.to_table() >>> cat.release_cache() """ self._batch_arrays_cache.clear() @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 the 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. It must have one element per source (the same length as the catalog), or be a scalar for a single-source (scalar) catalog. overwrite : bool, optional If `True`, overwrite an existing user-defined property ``name``. Built-in attributes can never be overwritten. Raises ------ ValueError If ``name`` is a built-in attribute, if ``name`` already exists and ``overwrite`` is `False`, or if ``value`` does not have the same number of elements as the catalog. """ # dir() includes all class-level attributes (properties, cached # properties, and methods), so built-in methods (e.g., to_table) # cannot be clobbered even with overwrite=True internal_attributes = ((set(self.__dict__.keys()) | set(dir(self.__class__))) - 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. Raises ------ ValueError If any name in ``names`` is not a user-defined property. """ 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. Raises ------ ValueError If ``name`` is not a user-defined property. """ if name not in self._custom_properties: msg = f'{name} is not a defined property' raise ValueError(msg) 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) @cached_property 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) @cached_property 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 @cached_property def _data_cutouts(self): """ A list of data cutouts using the segmentation image slices. """ return [self._data[slc] for slc in self._slices] @cached_property 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] @cached_property def _mask_cutouts(self): """ A list of mask cutouts using the segmentation image slices. If the input ``mask`` is None then an array of `None` values is returned. """ if self._mask is None: return self._null_objects return [self._mask[slc] for slc in self._slices] @cached_property def _error_cutouts(self): """ A list of error cutouts using the segmentation image slices. If the input ``error`` is None then an array of `None` values is returned. """ if self._error is None: return self._null_objects return [self._error[slc] for slc in self._slices] @cached_property 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] @cached_property 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] @staticmethod def _make_cutout_data_mask(data_cutout, mask_cutout): """ Make a cutout data mask, combining both the input ``mask`` and nonfinite ``data`` values. """ data_mask = ~np.isfinite(data_cutout) if mask_cutout is not None: data_mask |= mask_cutout return data_mask @cached_property 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)] @cached_property def _cutout_total_masks(self): """ Cutout boolean masks combining the segment mask, nonfinite ``data`` values, and the input ``mask`` array. Each mask is `True` for pixels outside the source segment, for nonfinite ``data`` values, and where the input ``mask`` is `True`. These masks are applied to the masked cutout properties. """ return [segm_mask | self._make_cutout_data_mask(data_cutout, mask_cutout) for segm_mask, data_cutout, mask_cutout in zip(self._cutout_segment_masks, self._data_cutouts, self._mask_cutouts, strict=True)] 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``. Raises ------ ValueError If ``label`` is not a label in this catalog. """ return self.select_labels(label) def select_labels(self, labels): """ Return a new `SourceCatalog` object for the input ``labels`` only. Parameters ---------- labels : int, 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``. Raises ------ ValueError If any of the input ``labels`` is not a label in this catalog. """ bad_labels = np.setdiff1d(labels, self.labels) if bad_labels.size > 0: bad_labels = [int(label) for label in 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 (not in ' 'this catalog)') raise ValueError(msg) 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 the names in the `properties` attribute, except for the 2D cutout image properties (e.g., ``data_cutout``), and any 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 source properties with one row per source. Raises ------ ValueError If any name in ``columns`` is not a valid (or deprecated) column name. """ if columns is None: table_columns = self.default_columns else: allowed_columns = (set(self.properties) | set(self.custom_properties)) # The deprecated inertia_tensor is not a cached property allowed_columns.add('inertia_tensor') # Remove 2D cutout images from the allowed columns allowed_columns = {col for col in allowed_columns if '_cutout' not in col} deprecated_names = _DEPRECATED_ATTRIBUTES.copy() # These are not valid column names, but are deprecated # attributes that are still accessible in 3.x. They will be # removed in 4.0. invalid = ('add_extra_property', 'apermask_method', 'background', 'background_ma', 'convdata', 'convdata_ma', 'data', 'data_ma', 'error', 'error_ma', 'extra_properties', 'fluxfrac_radius', 'get_label', 'get_labels', 'localbkg_width', 'nlabels', 'remove_extra_properties', 'remove_extra_property', 'rename_extra_property', 'segment', 'segment_ma') for name in invalid: deprecated_names.pop(name) table_columns = validate_table_columns( columns, allowed_columns, deprecated_names=deprecated_names) # 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,) # Use the canonical (nondeprecated) column name so that # assigning into ``tbl`` does not trigger a second, # redundant deprecation warning (the deprecated attribute # access above already warned once). canonical_column = _DEPRECATED_ATTRIBUTES.get(column, column) tbl[canonical_column] = values return tbl @cached_property def n_labels(self): """ The number of source labels. """ return len(self.labels) @property 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 multisource 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 def slices(self): """ A tuple of slice objects defining the minimal bounding box of the source. Returns a list for multisource catalogs, or a single tuple for a single-source catalog. """ return self._slices @cached_property 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 multisource catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts( self._segmentation_image_cutouts, units=False, masked=False) @cached_property 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 nonfinite ``data`` values (NaN and inf). Returns a list of arrays for multisource catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts( self._segmentation_image_cutouts, units=False, masked=True) @cached_property def data_cutout(self): """ A 2D `~numpy.ndarray` (or `~astropy.units.Quantity` if the input ``data`` has units) cutout from the data using the minimal bounding box of the source. Returns a list of arrays for multisource catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._data_cutouts, units=True, masked=False, dtype=float) @cached_property 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 nonfinite ``data`` values (NaN and inf). The masked array does not have units, even if the input ``data`` is a `~astropy.units.Quantity`. Returns a list of arrays for multisource catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._data_cutouts, units=False, masked=True, dtype=float) @cached_property def conv_data_cutout(self): """ A 2D `~numpy.ndarray` (or `~astropy.units.Quantity` if the input ``data`` has units) cutout from the convolved data using the minimal bounding box of the source. Returns a list of arrays for multisource catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._convdata_cutouts, units=True, masked=False, dtype=float) @cached_property 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 nonfinite ``data`` values (NaN and inf). The masked array does not have units, even if the input ``data`` is a `~astropy.units.Quantity`. Returns a list of arrays for multisource catalogs, or a single array for a single-source catalog. """ return self._prepare_cutouts(self._convdata_cutouts, units=False, masked=True, dtype=float) @cached_property def error_cutout(self): """ A 2D `~numpy.ndarray` (or `~astropy.units.Quantity` if the input ``data`` has units) cutout from the error array using the minimal bounding box of the source. If ``error`` was not input, then `None` is returned for each source. Returns a list of arrays for multisource 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) @cached_property 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 nonfinite ``data`` values (NaN and inf). The masked array does not have units, even if the input ``data`` is a `~astropy.units.Quantity`. If ``error`` was not input, then `None` is returned for each source. Returns a list of arrays for multisource 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) @cached_property def background_cutout(self): """ A 2D `~numpy.ndarray` (or `~astropy.units.Quantity` if the input ``data`` has units) cutout from the background array using the minimal bounding box of the source. If ``background`` was not input, then `None` is returned for each source. Returns a list of arrays for multisource 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) @cached_property 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 nonfinite ``data`` values (NaN and inf). The masked array does not have units, even if the input ``data`` is a `~astropy.units.Quantity`. If ``background`` was not input, then `None` is returned for each source. Returns a list of arrays for multisource 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) @cached_property def _segment_pixel_flags(self): """ The bitwise OR of the pixel condition bits over each source segment, as a uint8 array. Bit 1 (value 1) is set if any segment pixel is input-masked, bit 2 (value 2) if any segment ``data`` value is nonfinite, and bit 3 (value 4) if any segment ``error`` value is nonfinite. The bits are evaluated on every segment pixel, including input-masked pixels. """ arrays = self._get_batch_arrays() pixel_flags = arrays['mask'].copy() if arrays['error'] is not None: pixel_flags[~np.isfinite(arrays['error'])] |= 4 # Gather the pixel flags of every segment pixel (the all-zero # mask excludes nothing) and reduce them per source. The flag # values are exact in float32, which halves the temporary image. packed, offsets, counts = self._threaded_batch( batch_segment_gather, self._batch_bbox_args(), merge=_concatenate_segment_gathers, values=pixel_flags.astype(np.float32), mask=np.zeros(pixel_flags.shape, dtype=np.uint8), segm=arrays['segm']) # A source without segment pixels holds a single NaN # placeholder, whose integer cast is undefined with np.errstate(invalid='ignore'): packed = packed.astype(np.uint8) segment_flags = np.bitwise_or.reduceat(packed, offsets[:-1]) segment_flags[counts == 0] = 0 return segment_flags @cached_property def _all_masked(self): """ True if all pixels over the source segment are masked. """ return self._data_values.counts == 0 @cached_property @_update_flags_docstring def flags(self): # numpydoc ignore: RT01 """ The per-source bitwise quality flags. The flags combine deblending-provenance flags carried by the input segmentation image with measurement-time flags computed from this catalog's ``data``, ``convolved_data``, ``mask``, ``error``, and segmentation image. Use `~photutils.segmentation.decode_segmentation_flags` to decode the values. If a ``detection_catalog`` was input, the shape (``undefined_shape``, ``singular_covariance``) and Kron aperture geometry (``kron_undefined``, ``kron_minimum_radius``) flags derive from the detection catalog. The ``kron_undefined`` flag is also set for a source that is completely masked in this catalog, whose Kron flux is NaN. The Kron photometry-loop flags (``kron_no_overlap``, ``kron_partial_overlap``, ``kron_masked_pixels``, ``kron_non_finite_data``, ``kron_non_finite_error``, ``kron_neighbor_pixels``, ``kron_uncorrected_pixels``), like the segment-level edge, mask, nonfinite, ``all_masked``, and ``local_bkg_too_few_pixels`` flags, are evaluated on this catalog's own inputs. Accessing ``flags`` computes the moment, covariance, local background, and Kron-aperture properties if they have not already been computed (the results are cached and reused by those properties). The windowed and quadratic centroid fallbacks are reported by the `centroid_win_fallback` and `centroid_quad_fallback` properties instead of by flags, so that the flags do not require computing those centroids. The flags are: """ flags = np.zeros(len(self.labels), dtype=int) # Deblending provenance flags from the segmentation image flags_map = self._segmentation_image._flags_map if flags_map: flags |= np.array([flags_map.get(int(label), 0) for label in self.labels]) # Source segment touches an image boundary ny, nx = self._data.shape edge = np.array([(slc[0].start == 0 or slc[1].start == 0 or slc[0].stop == ny or slc[1].stop == nx) for slc in self._slices]) flags[edge] |= SEGMENTATION_FLAGS.EDGE_TOUCH # Input-masked, nonfinite data, and nonfinite error pixels # within the source segment pixel_flags = self._segment_pixel_flags flags[(pixel_flags & 1) > 0] |= SEGMENTATION_FLAGS.MASKED_PIXELS flags[(pixel_flags & 2) > 0] |= SEGMENTATION_FLAGS.NON_FINITE_DATA flags[(pixel_flags & 4) > 0] |= SEGMENTATION_FLAGS.NON_FINITE_ERROR # All pixels within the source segment are masked flags[self._all_masked] |= SEGMENTATION_FLAGS.ALL_MASKED # Too few usable local background annulus pixels, so the local # background is zero if self.local_bkg_width > 0: too_few = self._local_bkg_n_pixels < _LOCAL_BKG_MIN_PIXELS flags[too_few] |= SEGMENTATION_FLAGS.LOCAL_BKG_TOO_FEW_PIXELS # Nonpositive net flux (the zeroth image moment over the # source segment). The moment-derived shape properties are # undefined. Fully-masked sources and sources with no positive # (convolved) data values have a zero net flux, so they are also # flagged here. The ``~(m00 > 0)`` form is defensive against # a nonfinite net flux, which cannot currently occur because # the moment cutouts are zero-filled for masked and nonfinite # pixels, but the form would still catch it. m00 = self._array('moments')[:, 0, 0] flags[~(m00 > 0)] |= SEGMENTATION_FLAGS.UNDEFINED_SHAPE # Covariance matrix that is not positive semidefinite. The # covariance-derived shape properties are NaN. This matches # the equivalent aperture flag. It is not expected here because # negative (convolved) data values are set to zero in the image # moments. invalid = is_invalid_covariance( self._raw_covariance, determinant=self._raw_covariance_det) flags[invalid] |= SEGMENTATION_FLAGS.UNDEFINED_SHAPE # Singular, nearly singular, or rank-1 degenerate source # covariance, evaluated on the raw (unregularized) covariance # matrix flags[self._singular_covariance_mask] |= ( SEGMENTATION_FLAGS.SINGULAR_COVARIANCE) # Kron-aperture flags flags |= self._kron_flags # Minimum Kron radius or minimum circular radius applied. # _measured_kron_radius is the unscaled measured value, which # _calc_kron_radius clips to kron_params[1] and sets to zero # when the minimum circular aperture is used instead. A # measured value equal to the minimum is also flagged because # _measured_kron_radius returns exactly kron_params[1] when the # Kron numerator or denominator is not positive. NaN comparisons # are False, so sources with an undefined Kron radius are not # flagged here (they are flagged as kron_undefined instead). The # Kron radii are measured by the detection catalog, if input, so # its kron_params define the applied minimum. detcat = (self if self._detection_catalog is None else self._detection_catalog) measured = self._measured_kron_radius kron_radius = self._array('kron_radius').value min_applied = ((measured <= detcat.kron_params[1]) | (kron_radius == 0)) flags[min_applied] |= SEGMENTATION_FLAGS.KRON_MINIMUM_RADIUS return flags def decode_flags(self, *, return_bit_values=False): """ Decode the source quality flags into individual components. This is a convenience method that calls `~photutils.segmentation.decode_segmentation_flags` with the `flags` property. Parameters ---------- return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag names (strings). Returns ------- decoded : dict A dictionary mapping each source label to the list of its active flag names (or bit values). The entries follow the catalog order. See Also -------- photutils.segmentation.decode_segmentation_flags Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.segmentation import SourceCatalog, detect_sources >>> yy, xx = np.mgrid[0:51, 0:51] >>> data = (Gaussian2D(100, 25, 25, 3, 3)(xx, yy) ... + Gaussian2D(100, 2, 40, 3, 3)(xx, yy)) >>> segm = detect_sources(data, 10, 5) >>> cat = SourceCatalog(data, segm) >>> for label, names in cat.decode_flags().items(): ... print(label, names) 1 [] 2 ['edge_touch', 'kron_partial_overlap'] """ decoded = decode_segmentation_flags( self._array('flags'), return_bit_values=return_bit_values) return {int(label): flags for label, flags in zip(self.labels, decoded, strict=True)} def _segment_gather(self, array): """ Pack the unmasked values of ``array`` within each source segment into one array (see ``batch_segment_gather``). Parameters ---------- array : 2D `~numpy.ndarray` The C-contiguous full-image array (float32 or float64) to gather from. Returns ------- packed : 1D `~numpy.ndarray` The packed values of all sources, in row-major pixel order, with a single NaN for a completely-masked source. offsets : 1D `~numpy.ndarray` The start offset of each source in ``packed`` (length ``n_labels + 1``). counts : 1D `~numpy.ndarray` The number of unmasked segment pixels of each source. """ arrays = self._get_batch_arrays() return self._threaded_batch( batch_segment_gather, self._batch_bbox_args(), merge=_concatenate_segment_gathers, values=array, mask=arrays['mask'], segm=arrays['segm']) def _segment_values(self, array): """ Get the unmasked values of ``array`` within each source segment as a packed `_SegmentValues` container. A single NaN is held for completely-masked sources. Parameters ---------- array : 2D `~numpy.ndarray` The C-contiguous full-image array (float32 or float64) to gather from. Returns ------- result : `_SegmentValues` The per-source values, in row-major pixel order. """ return _SegmentValues(*self._segment_gather(array)) @cached_property def _data_values(self): """ The unmasked data values within each source segment, as a packed `_SegmentValues` container. A single NaN is held for completely-masked sources. """ return self._segment_values(self._get_batch_arrays()['data']) @cached_property def _error_values(self): """ The unmasked error values within each source segment, as a packed `_SegmentValues` container, or `None` if no error array was input. A single NaN is held for completely-masked sources. """ if self._error is None: return None return self._segment_values(self._get_batch_arrays()['error']) @cached_property def _background_values(self): """ The unmasked background values within each source segment, as a packed `_SegmentValues` container, or `None` if no background array was input. A single NaN is held for completely-masked sources. """ if self._background is None: return None return self._segment_values(self._get_batch_background()) @cached_property @use_detcat def moments(self): """ Spatial moments up to 3rd order of the source. """ arrays = self._get_batch_arrays() return self._threaded_batch( batch_raw_moments, self._batch_bbox_args(), convdata=self._get_batch_convdata(), mask=arrays['mask'], segm=arrays['segm']) @cached_property @use_detcat def moments_central(self): """ Central moments (translation invariant) of the source up to 3rd order. """ arrays = self._get_batch_arrays() cutout_centroid = self._array('cutout_centroid') per_source = self._batch_bbox_args() per_source['xcen'] = np.ascontiguousarray(cutout_centroid[:, 0]) per_source['ycen'] = np.ascontiguousarray(cutout_centroid[:, 1]) return self._threaded_batch( batch_central_moments, per_source, convdata=self._get_batch_convdata(), mask=arrays['mask'], segm=arrays['segm']) @cached_property @use_detcat 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. """ return centroid_from_moments(self._array('moments')) @cached_property @use_detcat 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 @cached_property @use_detcat 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._array('centroid')[:, 0] @cached_property @use_detcat 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._array('centroid')[:, 1] @cached_property @use_detcat def _centroid_err_cov(self): """ The ``(n_labels, 2, 2)`` pixel error covariance matrices of the isophotal centroid, ``[[var_x, cov_xy], [cov_xy, var_y]]``. The matrices are all-NaN where the errors cannot be computed (no ``error`` input or nonpositive total flux). See `centroid_err` for the propagation details. """ if self._error is None: return np.full((self.n_labels, 2, 2), np.nan) arrays = self._get_batch_arrays() cutout_centroid = self._array('cutout_centroid') per_source = self._batch_bbox_args() per_source['xcen'] = np.ascontiguousarray(cutout_centroid[:, 0]) per_source['ycen'] = np.ascontiguousarray(cutout_centroid[:, 1]) # The accumulators are the summed pixel variance and its # dx**2, dy**2, and dx*dy weighted sums. The variance is zeroed # at masked pixels and at pixels that were excluded from the # moment data (e.g., nonfinite or negative convolved values), # so that pixels that do not contribute flux weight also do not # contribute error variance. acc = self._threaded_batch( batch_moment_err, per_source, error=arrays['error'], convdata=self._get_batch_convdata(), mask=arrays['mask'], segm=arrays['segm']) # The total flux is the zeroth raw moment of the moment data. total_flux = self._array('moments')[:, 0, 0] # Ignore divide-by-zero and invalid-value RuntimeWarnings for # sources with nonpositive or nonfinite total flux. Those # values are replaced by NaN below. with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) # Propagate the error through the centroid formula. norm = 1.0 / total_flux**2 err_var_x = acc[:, 1] * norm err_var_y = acc[:, 2] * norm err_cov_xy = acc[:, 3] * norm # The variance of a uniform distribution across a single # pixel (1/12) scaled by the summed pixel variance. err_sum_norm = acc[:, 0] * PIXEL_VARIANCE * norm cov = np.empty((len(err_var_x), 2, 2)) cov[:, 0, 0] = err_var_x cov[:, 1, 1] = err_var_y cov[:, 0, 1] = err_cov_xy cov[:, 1, 0] = err_cov_xy # Singularity correction. The pixels that carry the error can # lie along a line or at a single point (e.g., an unresolved # source), which gives a nearly singular error covariance with # an error close to zero along one or both principal axes. The # error variance along each principal axis is raised to at # least ``err_sum_norm``. The principal axes are kept, and an # axis whose error variance already exceeds the floor is not # modified. The test is made on the error covariance itself, so # it needs no knowledge of the source shape. idx = np.flatnonzero(np.isfinite(cov).all(axis=(1, 2)) & np.isfinite(err_sum_norm)) cov[idx] = floor_covariance_eigvals(cov[idx], minimum=err_sum_norm[idx]) bad = ~np.isfinite(total_flux) | (total_flux <= 0) cov[bad] = np.nan return cov def _sky_err_from_cov(self, pix_cov, xycen): """ Transport pixel error covariances to sky position errors. The returned errors are the square roots of the tangent-plane variances along East and North (see ``pixel_to_sky_covariance`` in ``photutils.utils._moments``). Parameters ---------- pix_cov : `~numpy.ndarray` The ``(N, 2, 2)`` pixel error covariance matrices. xycen : `~numpy.ndarray` The ``(N, 2)`` pixel ``(x, y)`` centroid positions at which to evaluate the WCS Jacobians. Returns ------- sky_err : `~astropy.units.Quantity` The ``(N, 2)`` array of 1-sigma errors in arcsec, with columns ``(east_err, north_err)``. Rows are NaN where the position or covariance is not finite. """ sky_cov = pixel_to_sky_covariance(self.wcs, pix_cov, xycen) sky_err = np.sqrt(np.stack((sky_cov[:, 0, 0], sky_cov[:, 1, 1]), axis=1)) return sky_err << u.arcsec @cached_property @use_detcat def centroid_err(self): """ The ``(x, y)`` 1-sigma errors on the `centroid` position. The errors are computed by propagating the input ``error`` array through the center-of-mass centroid formula. If ``error`` was not input, then the errors are NaN. If a ``detection_catalog`` was input, then the errors are those of the detection catalog, computed from its ``error`` array. For a centroid defined as :math:`x_c = \\sum f_i x_i / F`, the variance is: .. math:: \\text{Var}(x_c) = \\frac{\\sum_i (x_i - x_c)^2 \\sigma_i^2}{F^2} The error variance along each principal axis of the error covariance matrix is at least :math:`\\sum_i \\sigma_i^2 / (12 F^2)`, the variance of a uniform distribution across a single pixel scaled by the summed pixel variance. This prevents an error close to zero along an axis where the pixels that carry the error span less than a pixel (e.g., an unresolved source). An error variance that already exceeds this value is unchanged. """ cov = self._centroid_err_cov return np.sqrt(np.stack((cov[:, 0, 0], cov[:, 1, 1]), axis=1)) @cached_property @use_detcat def x_centroid_err(self): """ The 1-sigma error on the ``x`` coordinate of the `centroid`. See `centroid_err` for details. If ``error`` was not input, then the errors are NaN. """ return self._array('centroid_err')[:, 0] @cached_property @use_detcat def y_centroid_err(self): """ The 1-sigma error on the ``y`` coordinate of the `centroid`. See `centroid_err` for details. If ``error`` was not input, then the errors are NaN. """ return self._array('centroid_err')[:, 1] def _normalize_centroid_win_err(self, err_sum, err_var_x, err_var_y, err_cov_xy, weighted_flux): """ Normalize the windowed centroid position-error variances. Normalize by ``step_factor^2 / weighted_flux^2`` and add the pixel-size (1/12) variance correction. All inputs are arrays with one element per source. Sources with nonpositive or nonfinite ``weighted_flux`` have ``np.nan`` variances. No separate singularity correction is needed (compare ``_centroid_err_cov``). The pixel-size correction is added to both variances of a positive semidefinite matrix, so the error variance along every principal axis is already at least that correction. Parameters ---------- err_sum : `~numpy.ndarray` Sum of the weighted error variance over the aperture. err_var_x : `~numpy.ndarray` Weighted error variance in ``x``. err_var_y : `~numpy.ndarray` Weighted error variance in ``y``. err_cov_xy : `~numpy.ndarray` Weighted error covariance in ``xy``. weighted_flux : `~numpy.ndarray` Total weighted flux from the last iteration. Returns ------- err_var_x : `~numpy.ndarray` Normalized error variance in ``x``. err_var_y : `~numpy.ndarray` Normalized error variance in ``y``. err_cov_xy : `~numpy.ndarray` Normalized error covariance in ``xy``. The pixel-size correction is not applied to the covariance. """ step_factor = 2.0 with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) norm = step_factor**2 / weighted_flux**2 # Add the pixel-size variance correction (1/12). # The finite-pixel term is added per pixel. err_sum_norm = err_sum * PIXEL_VARIANCE * norm err_var_x = err_var_x * norm + err_sum_norm err_var_y = err_var_y * norm + err_sum_norm err_cov_xy = err_cov_xy * norm bad = ~np.isfinite(weighted_flux) | (weighted_flux <= 0) err_var_x[bad] = np.nan err_var_y[bad] = np.nan err_cov_xy[bad] = np.nan return err_var_x, err_var_y, err_cov_xy def _apply_centroid_win_fallback(self, xcen_win, ycen_win, win_weighted_flux, win_cen_mom_xx, win_cen_mom_yy, win_cen_mom_xy, win_err_var_x, win_err_var_y, win_err_cov_xy, nan_hl, x_centroid, y_centroid): """ Apply the fallback conditions for the windowed centroid. Reset the centroid to the isophotal centroid when any of the following conditions hold: * The centroid diverged far from the isophotal centroid and lies outside the 1-sigma ellipse * The total weighted flux is nonpositive * The windowed 2nd-order moments are negative * The windowed covariance determinant is negative * The windowed centroid is not finite Fallback sources also use the isophotal centroid errors. Sources with NaN half-light radius keep NaN. Parameters ---------- xcen_win : `~numpy.ndarray` Windowed x centroids. ycen_win : `~numpy.ndarray` Windowed y centroids. win_weighted_flux : `~numpy.ndarray` Weighted flux from each source. win_cen_mom_xx : `~numpy.ndarray` Windowed central 2nd-order moment xx. win_cen_mom_yy : `~numpy.ndarray` Windowed central 2nd-order moment yy. win_cen_mom_xy : `~numpy.ndarray` Windowed central 2nd-order moment xy. win_err_var_x : `~numpy.ndarray` Error variance in x. win_err_var_y : `~numpy.ndarray` Error variance in y. win_err_cov_xy : `~numpy.ndarray` Error covariance in xy. nan_hl : `~numpy.ndarray` Boolean mask indicating sources with NaN half-light radius. x_centroid : `~numpy.ndarray` The isophotal x centroids. y_centroid : `~numpy.ndarray` The isophotal y centroids. Returns ------- result : `~numpy.ndarray` The windowed centroid coordinates, their error variances and covariance, and the fallback indicator as columns ``(x, y, var_x, var_y, cov_xy, fallback)``, shape ``(n_sources, 6)``. The ``fallback`` column is 1.0 for sources whose windowed centroid was reset to the isophotal centroid or is NaN (nonfinite half-light radius), and 0.0 otherwise. """ dx = x_centroid - xcen_win dy = y_centroid - ycen_win cxx = self._array('ellipse_cxx').value cxy = self._array('ellipse_cxy').value cyy = self._array('ellipse_cyy').value reset = ((dx**2 > 1) & (dy**2 > 1) & ((cxx * dx**2 + cxy * dx * dy + cyy * dy**2) > 1)) reset |= win_weighted_flux <= 0.0 reset |= (win_cen_mom_xx < 0.0) | (win_cen_mom_yy < 0.0) reset |= (win_cen_mom_xx * win_cen_mom_yy - win_cen_mom_xy**2) < 0.0 nan_cen = np.isnan(xcen_win) | np.isnan(ycen_win) reset |= nan_cen & ~nan_hl reset &= ~nan_hl if np.any(reset): xcen_win[reset] = x_centroid[reset] ycen_win[reset] = y_centroid[reset] # Fallback sources use the isophotal centroid, so also # use the isophotal centroid error covariance iso_cov = self._centroid_err_cov win_err_var_x[reset] = iso_cov[reset, 0, 0] win_err_var_y[reset] = iso_cov[reset, 1, 1] win_err_cov_xy[reset] = iso_cov[reset, 0, 1] fallback = (reset | nan_hl).astype(float) return np.transpose((xcen_win, ycen_win, win_err_var_x, win_err_var_y, win_err_cov_xy, fallback)) @cached_property @use_detcat def _centroid_win_results(self): """ The "windowed" centroid coordinates, their error variances and covariance, and the fallback indicator as a 2D array with columns ``(x, y, var_x, var_y, cov_xy, fallback)`` and shape ``(n_labels, 6)``. The ``fallback`` column is 1.0 for sources whose windowed centroid was reset to the isophotal centroid or is NaN, and 0.0 otherwise. This is the single computation behind `centroid_win`, `centroid_win_err`, and `centroid_win_fallback`. See `centroid_win` for the algorithm details. """ # 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 nonfinite 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 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 compute_err = self._error is not None # Centroids as iterable arrays regardless of scalar state x_centroid = np.atleast_1d(self.x_centroid).astype(float) y_centroid = np.atleast_1d(self.y_centroid).astype(float) sigma = 2.0 * radius_hl * gaussian_fwhm_to_sigma skip = (nan_hl | ~np.isfinite(x_centroid) | ~np.isfinite(y_centroid)).astype(np.uint8) arrays = self._get_batch_arrays() results = self._threaded_batch( batch_centroid_win, {'labels': self._batch_labels(), 'xcen0': x_centroid, 'ycen0': y_centroid, 'sigma': sigma, 'skip': skip}, data=arrays['data'], error=arrays['error'], mask=arrays['mask'], segm=arrays['segm'], seg_method=SEG_METHOD_CODES[self.aperture_mask_method], compute_err=int(compute_err), max_aper_size=max(self._data.size, 1_000_000)) (xcen_win, ycen_win, win_weighted_flux, win_cen_mom_xx, win_cen_mom_yy, win_cen_mom_xy, win_err_sum, win_err_var_x, win_err_var_y, win_err_cov_xy) = results.T.copy() # Normalize error terms by step_factor^2 / weighted_flux^2 if compute_err: (win_err_var_x, win_err_var_y, win_err_cov_xy) = self._normalize_centroid_win_err( win_err_sum, win_err_var_x, win_err_var_y, win_err_cov_xy, win_weighted_flux) return self._apply_centroid_win_fallback( xcen_win, ycen_win, win_weighted_flux, win_cen_mom_xx, win_cen_mom_yy, win_cen_mom_xy, win_err_var_x, win_err_var_y, win_err_cov_xy, nan_hl, x_centroid, y_centroid) @cached_property @use_detcat def centroid_win_fallback(self): """ A boolean array that is `True` where the windowed centroid (`centroid_win`) fell back to the isophotal `centroid` or is NaN. See `centroid_win` for the fallback conditions. """ return self._centroid_win_results[:, 5].astype(bool) @cached_property @use_detcat 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 radius ``4 * sigma`` centered on 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 predefined threshold or a maximum number of iterations is reached. The windowed centroid is reset to the isophotal `centroid` if any of the following conditions hold: the centroid diverged far from the isophotal centroid and lies outside the 1-sigma ellipse, the total weighted flux is nonpositive, the windowed 2nd-order moments are negative, the windowed covariance determinant is negative, or the windowed centroid is not finite. If the half-light radius is not finite (e.g., due to a nonfinite Kron radius), then ``np.nan`` will be returned. """ return self._centroid_win_results[:, 0:2].copy() @cached_property @use_detcat 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. """ return self._array('centroid_win')[:, 0] @cached_property @use_detcat 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. """ return self._array('centroid_win')[:, 1] @cached_property @use_detcat def centroid_win_err(self): """ The ``(x, y)`` 1-sigma errors on the "windowed" centroid (`centroid_win`) position. The errors are computed by propagating the input ``error`` array through the Gaussian-weighted windowed centroid formula. If ``error`` was not input, then the errors are NaN. If a ``detection_catalog`` was input, then the errors are those of the detection catalog, computed from its ``error`` array. Sources where the windowed centroid fell back to the isophotal `centroid` have the isophotal centroid errors (see `centroid_err`). The errors are NaN where the half-light radius is not finite. """ results = self._centroid_win_results with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return np.sqrt(results[:, 2:4]) @cached_property @use_detcat def _centroid_win_err_cov(self): """ The ``(n_labels, 2, 2)`` pixel error covariance matrices of the windowed centroid, ``[[var_x, cov_xy], [cov_xy, var_y]]``. Fallback sources hold the isophotal covariance (see ``_centroid_err_cov``). The matrices are all-NaN where errors are unavailable. """ results = self._centroid_win_results cov = np.empty((len(results), 2, 2)) cov[:, 0, 0] = results[:, 2] cov[:, 1, 1] = results[:, 3] cov[:, 0, 1] = results[:, 4] cov[:, 1, 0] = results[:, 4] return cov @cached_property @use_detcat def x_centroid_win_err(self): """ The 1-sigma error on the ``x`` coordinate of the "windowed" centroid (`centroid_win`). See `centroid_win_err` for details. If ``error`` was not input, then the errors are NaN. """ return self._array('centroid_win_err')[:, 0] @cached_property @use_detcat def y_centroid_win_err(self): """ The 1-sigma error on the ``y`` coordinate of the "windowed" centroid (`centroid_win`). See `centroid_win_err` for details. If ``error`` was not input, then the errors are NaN. """ return self._array('centroid_win_err')[:, 1] @cached_property @use_detcat 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. See `centroid_win` for further details about the algorithm. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.centroid_win - origin @cached_property @use_detcat def _centroid_quad_results(self): """ The quadratic centroid coordinates, relative to the cutout data, their error variances and covariance, and the fallback indicator as a 2D array with columns ``(x, y, var_x, var_y, cov_xy, fallback)`` and shape ``(n_labels, 6)``. The ``fallback`` column is 1.0 for sources whose centroid is not the maximum of a quadratic fit (the peak pixel position, the isophotal centroid, or NaN), and 0.0 otherwise. This is the single computation behind `cutout_centroid_quad`, `centroid_quad`, `centroid_quad_err`, and `centroid_quad_fallback`. See `cutout_centroid_quad` for the algorithm details. """ # 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. 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) compute_err = self._error is not None # Gather the 3x3 box of data values around the peak pixel of # each masked (zero-filled) cutout. arrays = self._get_batch_arrays() iymin, iymax, ixmin, ixmax = self._get_batch_bboxes() status, peak, boxes, box_var, fitted = self._threaded_batch( batch_quad_boxes, self._batch_bbox_args(), merge=_concatenate_arrays, data=arrays['data'], error=arrays['error'], mask=arrays['mask'], segm=arrays['segm'], compute_err=int(compute_err)) nx = ixmax - ixmin ny = iymax - iymin n_src = len(status) results = np.full((n_src, 6), np.nan) # If the peak is at the edge of the cutout, return the peak # position. No fit is performed, so no errors can be propagated. edge = status == 3 results[edge, 0] = peak[edge, 0] results[edge, 1] = peak[edge, 1] # Fit the quadratic to the 3x3 box of the remaining sources. fit = status == 0 xidx0 = peak[:, 0] - 1 yidx0 = peak[:, 1] - 1 # einsum (rather than a BLAS matmul) keeps the per-source # arithmetic independent of the number of sources, so a sliced # catalog reproduces the parent catalog values exactly. coeffs = np.einsum('ij,nj->ni', pinv, boxes) # Sources with masked pixels in the box are fit without those # pixels, using the pseudo-inverse of the design matrix with # the rows of the masked pixels set to zero. The fit needs a # design matrix of rank 6, which requires at least 6 pixels # that are not all in two rows or two columns of the box. partial_idx = np.flatnonzero(fit & ~np.all(fitted, axis=1)) if partial_idx.size > 0: design = (coeff_matrix * fitted[partial_idx, :, np.newaxis].astype(float)) partial_pinv = np.linalg.pinv(design) coeffs[partial_idx] = np.einsum('nij,nj->ni', partial_pinv, boxes[partial_idx]) deficient = np.linalg.matrix_rank(design) < 6 fit[partial_idx[deficient]] = False c10 = coeffs[:, 1] c01 = coeffs[:, 2] c11 = coeffs[:, 3] c20 = coeffs[:, 4] c02 = coeffs[:, 5] det = 4.0 * c20 * c02 - c11 * c11 fit &= ~((det <= 0) | (c20 > 0)) # Ignore RuntimeWarning from the sources without a fit with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) # Maximum in relative coords, then convert to cutout coords xm_rel = (c01 * c11 - 2.0 * c02 * c10) / det ym_rel = (c10 * c11 - 2.0 * c20 * c01) / det xm = xm_rel + xidx0 ym = ym_rel + yidx0 fit &= ((xm > 0.0) & (xm < (nx - 1.0)) & (ym > 0.0) & (ym < (ny - 1.0))) results[fit, 0] = xm[fit] results[fit, 1] = ym[fit] if compute_err: # Propagate the pixel variances through the quadratic # peak solution. The fit coefficients are linear in the # pixel values (``c = pinv @ pixels``) and the peak # position is a rational function of the coefficients, # so the position variances follow from the delta # method. Masked pixels are not in the fit, so they do # not contribute variance. zeros = np.zeros(n_src) grad_x = np.column_stack( [zeros, -2.0 * c02 / det, c11 / det, (c01 + 2.0 * c11 * xm_rel) / det, -4.0 * c02 * xm_rel / det, (-2.0 * c10 - 4.0 * c20 * xm_rel) / det]) grad_y = np.column_stack( [zeros, c11 / det, -2.0 * c20 / det, (c10 + 2.0 * c11 * ym_rel) / det, (-2.0 * c01 - 4.0 * c02 * ym_rel) / det, -4.0 * c20 * ym_rel / det]) u_x = np.einsum('ni,ij->nj', grad_x, pinv) u_y = np.einsum('ni,ij->nj', grad_y, pinv) if partial_idx.size > 0: u_x[partial_idx] = np.einsum( 'ni,nij->nj', grad_x[partial_idx], partial_pinv) u_y[partial_idx] = np.einsum( 'ni,nij->nj', grad_y[partial_idx], partial_pinv) results[fit, 2] = np.sum(u_x**2 * box_var, axis=1)[fit] results[fit, 3] = np.sum(u_y**2 * box_var, axis=1)[fit] results[fit, 4] = np.sum(u_x * u_y * box_var, axis=1)[fit] # Use the segment barycenter if the fit returned NaN. Fallback # sources use the isophotal centroid, so also use the isophotal # centroid error covariance. nan_mask = (np.isnan(results[:, 0]) | np.isnan(results[:, 1])) if np.any(nan_mask): cutout_centroid = self._array('cutout_centroid') results[nan_mask, 0:2] = cutout_centroid[nan_mask] iso_cov = self._centroid_err_cov results[nan_mask, 2] = iso_cov[nan_mask, 0, 0] results[nan_mask, 3] = iso_cov[nan_mask, 1, 1] results[nan_mask, 4] = iso_cov[nan_mask, 0, 1] results[:, 5] = ~fit return results @cached_property @use_detcat def cutout_centroid_quad(self): """ The ``(x, y)`` centroid coordinate, relative to the cutout data, calculated by fitting a 2D quadratic polynomial to the 3x3 pixel box centered on the peak pixel of the source segment. Notes ----- The fit follows `~photutils.centroids.centroid_quadratic` with ``fit_boxsize=3``, applied to the bounding-box cutout of the source. Pixels in the cutout that are outside the source segment, masked, or nonfinite are set to zero before the peak is located. Pixels of the 3x3 box that are masked (by the input ``mask``) or that have a nonfinite ``data`` value are left out of the fit. The other pixels of the box enter the fit with their ``data`` values, including pixels that are outside the source segment or that belong to a neighboring source. The isophotal centroid (`cutout_centroid`) is returned instead (NaN where the isophotal centroid is NaN) in these cases: * the bounding box of the source is smaller than 3 pixels along either axis * the pixels left in the fit do not determine the quadratic polynomial, which requires at least 6 pixels that are not all in two rows or two columns of the box * the quadratic fit does not have a maximum * the quadratic fit maximum falls on or outside the outermost pixel centers of the bounding-box cutout * there are no unmasked pixels within the source segment A fit is not performed if the peak pixel is at the edge of the bounding-box cutout. In this case, the position of the peak pixel is returned. """ return self._centroid_quad_results[:, 0:2].copy() @cached_property @use_detcat def centroid_quad(self): """ The ``(x, y)`` centroid coordinate, calculated by fitting a 2D quadratic polynomial to the 3x3 pixel box centered on the peak pixel of the source segment. Notes ----- The fit follows `~photutils.centroids.centroid_quadratic` with ``fit_boxsize=3``, applied to the bounding-box cutout of the source. Pixels in the cutout that are outside the source segment, masked, or nonfinite are set to zero before the peak is located. Pixels of the 3x3 box that are masked (by the input ``mask``) or that have a nonfinite ``data`` value are left out of the fit. The other pixels of the box enter the fit with their ``data`` values, including pixels that are outside the source segment or that belong to a neighboring source. The isophotal `centroid` is returned instead (NaN where the isophotal centroid is NaN) in these cases: * the bounding box of the source is smaller than 3 pixels along either axis * the pixels left in the fit do not determine the quadratic polynomial, which requires at least 6 pixels that are not all in two rows or two columns of the box * the quadratic fit does not have a maximum * the quadratic fit maximum falls on or outside the outermost pixel centers of the bounding-box cutout * there are no unmasked pixels within the source segment A fit is not performed if the peak pixel is at the edge of the bounding-box cutout. In this case, the position of the peak pixel is returned. The `centroid_quad_fallback` property is `True` for sources without a quadratic-fit centroid. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid_quad + origin @cached_property @use_detcat def centroid_quad_fallback(self): """ A boolean array that is `True` where the quadratic centroid (`centroid_quad`) is not the maximum of a quadratic fit. This is the case where the maximum data value is at the edge of the bounding-box cutout (the position of the maximum pixel is returned), where the fit failed (the isophotal `centroid` is returned), or where the centroid is NaN. See `centroid_quad` for details. """ return self._centroid_quad_results[:, 5].astype(bool) @cached_property @use_detcat def x_centroid_quad(self): """ The ``x`` coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the 3x3 pixel box centered on the peak pixel of the source segment. """ return self._array('centroid_quad')[:, 0] @cached_property @use_detcat def y_centroid_quad(self): """ The ``y`` coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the 3x3 pixel box centered on the peak pixel of the source segment. """ return self._array('centroid_quad')[:, 1] @cached_property @use_detcat def centroid_quad_err(self): """ The ``(x, y)`` 1-sigma errors on the quadratic centroid (`centroid_quad`) position. The errors are computed by propagating the input ``error`` array through the least-squares quadratic fit and its peak solution using the delta method. If ``error`` was not input, then the errors are NaN. If a ``detection_catalog`` was input, then the errors are those of the detection catalog, computed from its ``error`` array. Sources where the quadratic centroid fell back to the isophotal `centroid` have the isophotal centroid errors (see `centroid_err`). The errors are NaN for sources where the peak pixel is at the edge of the bounding-box cutout (where the position of the peak pixel is returned instead of a fit). The delta method linearizes the peak solution at the fitted coefficients, whose curvature terms are noisy for faint peaks. """ results = self._centroid_quad_results with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return np.sqrt(results[:, 2:4]) @cached_property @use_detcat def _centroid_quad_err_cov(self): """ The ``(n_labels, 2, 2)`` pixel error covariance matrices of the quadratic centroid, ``[[var_x, cov_xy], [cov_xy, var_y]]``. Sources whose centroid fell back to the isophotal centroid hold the isophotal covariance (see ``_centroid_err_cov``). The matrices are all-NaN where the errors are unavailable, including sources whose peak pixel position is returned instead of a fit. """ results = self._centroid_quad_results cov = np.empty((len(results), 2, 2)) cov[:, 0, 0] = results[:, 2] cov[:, 1, 1] = results[:, 3] cov[:, 0, 1] = results[:, 4] cov[:, 1, 0] = results[:, 4] return cov @cached_property @use_detcat def x_centroid_quad_err(self): """ The 1-sigma error on the ``x`` coordinate of the quadratic centroid (`centroid_quad`). See `centroid_quad_err` for details. If ``error`` was not input, then the errors are NaN. """ return self._array('centroid_quad_err')[:, 0] @cached_property @use_detcat def y_centroid_quad_err(self): """ The 1-sigma error on the ``y`` coordinate of the quadratic centroid (`centroid_quad`). See `centroid_quad_err` for details. If ``error`` was not input, then the errors are NaN. """ return self._array('centroid_quad_err')[:, 1] @cached_property @use_detcat 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) @cached_property @use_detcat def sky_centroid_err(self): """ The 1-sigma errors on the `sky_centroid` position as a ``(east, north)`` `~astropy.units.Quantity` in arcsec. The pixel error covariance of the `centroid` is transported to the local tangent plane with the WCS Jacobian evaluated at each source position. The first column is the error along East (the Right Ascension direction as a great-circle angle, i.e., including the cos(dec) factor) and the second is along North (Declination). The directions are those of the longitude and latitude axes of the celestial frame of the ``wcs``. They are Right Ascension and Declination only for an equatorial frame (e.g., ICRS). For another frame (e.g., Galactic), the errors are along the longitude and latitude of that frame. `None` if ``wcs`` is not input. NaN where the pixel errors are unavailable (e.g., no ``error`` input). """ if self.wcs is None: return self._null_objects xycen = self._array('centroid') return self._sky_err_from_cov(self._centroid_err_cov, xycen) @cached_property @use_detcat def sky_centroid_ra_err(self): """ The 1-sigma error on the `sky_centroid` position along the Right Ascension direction, as a great-circle angle in arcsec (i.e., including the cos(dec) factor). If the celestial frame of the ``wcs`` is not equatorial, this is the error along the longitude direction of that frame. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self._array('sky_centroid_err')[:, 0] @cached_property @use_detcat def sky_centroid_dec_err(self): """ The 1-sigma error on the `sky_centroid` position along the Declination direction, in arcsec. If the celestial frame of the ``wcs`` is not equatorial, this is the error along the latitude direction of that frame. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self._array('sky_centroid_err')[:, 1] @cached_property @use_detcat 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 @cached_property @use_detcat 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) @cached_property @use_detcat def sky_centroid_win_err(self): """ The 1-sigma errors on the `sky_centroid_win` position as a ``(east, north)`` `~astropy.units.Quantity` in arcsec. The pixel error covariance of the `centroid_win` is transported to the local tangent plane with the WCS Jacobian evaluated at each source position. The first column is the error along East (the Right Ascension direction as a great-circle angle, i.e., including the cos(dec) factor) and the second is along North (Declination). Fallback sources use the isophotal centroid error covariance. The directions are those of the longitude and latitude axes of the celestial frame of the ``wcs``. They are Right Ascension and Declination only for an equatorial frame (e.g., ICRS). For another frame (e.g., Galactic), the errors are along the longitude and latitude of that frame. `None` if ``wcs`` is not input. NaN where the pixel errors are unavailable (e.g., no ``error`` input). """ if self.wcs is None: return self._null_objects xycen = self._array('centroid_win') return self._sky_err_from_cov(self._centroid_win_err_cov, xycen) @cached_property @use_detcat def sky_centroid_win_ra_err(self): """ The 1-sigma error on the `sky_centroid_win` position along the Right Ascension direction, as a great-circle angle in arcsec (i.e., including the cos(dec) factor). If the celestial frame of the ``wcs`` is not equatorial, this is the error along the longitude direction of that frame. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self._array('sky_centroid_win_err')[:, 0] @cached_property @use_detcat def sky_centroid_win_dec_err(self): """ The 1-sigma error on the `sky_centroid_win` position along the Declination direction, in arcsec. If the celestial frame of the ``wcs`` is not equatorial, this is the error along the latitude direction of that frame. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self._array('sky_centroid_win_err')[:, 1] @cached_property @use_detcat def sky_centroid_quad(self): """ The sky coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the 3x3 pixel box centered on the peak pixel of 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_quad, self.y_centroid_quad) @cached_property @use_detcat def sky_centroid_quad_err(self): """ The 1-sigma errors on the `sky_centroid_quad` position as a ``(east, north)`` `~astropy.units.Quantity` in arcsec. The pixel error covariance of the `centroid_quad` is transported to the local tangent plane with the WCS Jacobian evaluated at each source position. The first column is the error along East (the Right Ascension direction as a great-circle angle, i.e., including the cos(dec) factor) and the second is along North (Declination). Sources where the quadratic centroid fell back to the isophotal `centroid` use the isophotal centroid error covariance. The errors are NaN for sources where the peak pixel position is returned instead of a fit. The directions are those of the longitude and latitude axes of the celestial frame of the ``wcs``. They are Right Ascension and Declination only for an equatorial frame (e.g., ICRS). For another frame (e.g., Galactic), the errors are along the longitude and latitude of that frame. `None` if ``wcs`` is not input. NaN where the pixel errors are unavailable (e.g., no ``error`` input). """ if self.wcs is None: return self._null_objects xycen = self._array('centroid_quad') return self._sky_err_from_cov(self._centroid_quad_err_cov, xycen) @cached_property @use_detcat def sky_centroid_quad_ra_err(self): """ The 1-sigma error on the `sky_centroid_quad` position along the Right Ascension direction, as a great-circle angle in arcsec (i.e., including the cos(dec) factor). If the celestial frame of the ``wcs`` is not equatorial, this is the error along the longitude direction of that frame. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self._array('sky_centroid_quad_err')[:, 0] @cached_property @use_detcat def sky_centroid_quad_dec_err(self): """ The 1-sigma error on the `sky_centroid_quad` position along the Declination direction, in arcsec. If the celestial frame of the ``wcs`` is not equatorial, this is the error along the latitude direction of that frame. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self._array('sky_centroid_quad_err')[:, 1] @cached_property @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] @cached_property @use_detcat def bbox(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region containing the source segment. Returns a list for multisource catalogs, or a single `~photutils.aperture.BoundingBox` for a single-source catalog. """ return self._bbox @cached_property @use_detcat 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]) @cached_property @use_detcat 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]) @cached_property @use_detcat 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]) @cached_property @use_detcat 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]) @cached_property @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]) @cached_property @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]) @cached_property @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]) @cached_property @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]) @cached_property @use_detcat 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)) @cached_property @use_detcat 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)) @cached_property @use_detcat 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)) @cached_property @use_detcat 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)) @cached_property def min_value(self): """ The minimum value of the unmasked ``data`` pixels within the source segment. The local background, if computed (``local_bkg_width`` > 0), is subtracted. The value is NaN for a completely masked source. """ values = self._data_values.reduce(np.minimum) values -= self._local_background if self._data_unit is not None: values <<= self._data_unit return values @cached_property def max_value(self): """ The maximum value of the unmasked ``data`` pixels within the source segment. The local background, if computed (``local_bkg_width`` > 0), is subtracted. The value is NaN for a completely masked source. """ values = self._data_values.reduce(np.maximum) values -= self._local_background if self._data_unit is not None: values <<= self._data_unit return values @cached_property def _cutout_minmax_index(self): """ The cutout-frame ``(y_min, x_min, y_max, x_max)`` positions of the first minimum and first maximum unmasked ``data`` value of each source, as an ``(n_labels, 4)`` intp array with -1 for a completely masked source (see ``batch_minmax_index``). """ arrays = self._get_batch_arrays() return self._threaded_batch( batch_minmax_index, self._batch_bbox_args(), values=arrays['data'], mask=arrays['mask'], segm=arrays['segm']) def _extreme_value_index(self, columns, *, origin): """ Return the ``(y, x)`` positions of an extreme ``data`` value of each source from ``_cutout_minmax_index``. Parameters ---------- columns : slice The two columns of ``_cutout_minmax_index`` to return. origin : bool Whether to add the bounding-box origin, giving positions in the full data array instead of the cutout frame. Returns ------- result : 2D `~numpy.ndarray` The ``(n_labels, 2)`` positions, as integers, or as floats with NaN rows for completely masked sources if there are any. """ # Private cached arrays keep their leading source axis for # scalar catalogs, so they are read directly (not via _array) index = self._cutout_minmax_index[:, columns] if origin: index = index + self._batch_bboxes[:, [0, 2]] all_masked = self._all_masked if np.any(all_masked): index = index.astype(float) index[all_masked] = np.nan return index @cached_property 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. The coordinates are integers. If any source in the catalog is completely masked, then the coordinates are returned as floats and are ``np.nan`` for that source. """ return self._extreme_value_index(slice(0, 2), origin=False) @cached_property 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. The coordinates are integers. If any source in the catalog is completely masked, then the coordinates are returned as floats and are ``np.nan`` for that source. """ return self._extreme_value_index(slice(2, 4), origin=False) @cached_property 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. The coordinates are integers. If any source in the catalog is completely masked, then the coordinates are returned as floats and are ``np.nan`` for that source. """ return self._extreme_value_index(slice(0, 2), origin=True) @cached_property 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. The coordinates are integers. If any source in the catalog is completely masked, then the coordinates are returned as floats and are ``np.nan`` for that source. """ return self._extreme_value_index(slice(2, 4), origin=True) @cached_property 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. """ return self._array('min_value_index')[:, 1] @cached_property 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. """ return self._array('min_value_index')[:, 0] @cached_property 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. """ return self._array('max_value_index')[:, 1] @cached_property 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. """ return self._array('max_value_index')[:, 0] @cached_property 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. Nonfinite pixel values (NaN and inf) are excluded (automatically masked). If ``local_bkg_width`` > 0, the local background times the number of unmasked pixels is also subtracted. The flux is NaN for a completely masked source. """ values = self._data_values source_sum = values.reduce(np.add) # Subtract the local background over the number of unmasked # pixels actually included in the sum. The "area" property is # not used here because it is taken from the detection catalog # (if input), whose pixel count can differ when the data masks # differ. source_sum -= values.counts * self._local_background if self._data_unit is not None: source_sum <<= self._data_unit return source_sum @cached_property 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 nonfinite pixel values (NaN and inf) that are automatically masked, are also masked in the error array. If ``error`` was not input, then the errors are NaN. """ if self._error is None: err = self._null_values else: err_sq = self._error_values.reduce(np.add, transform=np.square) err = np.sqrt(err_sq) if self._data_unit is not None: err <<= self._data_unit return err @cached_property def background_sum(self): """ The sum of ``background`` values within the source segment. Pixel values that are masked in the input ``data``, including any nonfinite pixel values (NaN and inf) that are automatically masked, are also masked in the background array. If ``background`` was not input, then the values are NaN. """ if self._background is None: bkg_sum = self._null_values else: bkg_sum = self._background_values.reduce(np.add) if self._data_unit is not None: bkg_sum <<= self._data_unit return bkg_sum @cached_property def background_mean(self): """ The mean of ``background`` values within the source segment. Pixel values that are masked in the input ``data``, including any nonfinite pixel values (NaN and inf) that are automatically masked, are also masked in the background array. If ``background`` was not input, then the values are NaN. """ if self._background is None: bkg_mean = self._null_values else: values = self._background_values # The placeholder slot of a completely masked source keeps # its (NaN) mean free of a division by zero bkg_mean = values.reduce(np.add) / values.sizes if self._data_unit is not None: bkg_mean <<= self._data_unit return bkg_mean @cached_property 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 ``background`` was not input, then the values are NaN. The value is also NaN where the `centroid` is not finite. """ if self._background is None: bkg = self._null_values else: xcen = np.atleast_1d(self.x_centroid) ycen = np.atleast_1d(self.y_centroid) # scipy >= 2.0 raises an error for nonfinite coordinates mask = np.isfinite(xcen) & np.isfinite(ycen) bkg = np.full(xcen.shape, np.nan) bkg[mask] = map_coordinates(self._background, (ycen[mask], xcen[mask]), output=np.float64, order=1, mode='nearest') if self._data_unit is not None: bkg <<= self._data_unit return bkg @cached_property @use_detcat 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). """ segm = self._segmentation_image areas = segm.areas[segm.get_indices(np.atleast_1d(self.labels))] return np.asarray(areas, dtype=int) << (u.pix**2) @cached_property @use_detcat 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). The area is NaN for a completely masked source. If ``detection_catalog`` is input, then its area is returned, which reflects the mask and ``data`` of the detection catalog. """ areas = self._data_values.counts.astype(float) areas[self._all_masked] = np.nan return areas << (u.pix**2) @cached_property @use_detcat def equivalent_radius(self): """ The radius of a circle with the same `area` as the source segment. """ return np.sqrt(self.area / np.pi) @cached_property @use_detcat 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 # Histogram of the convolved border-pixel patterns of each # source (see batch_perimeter). The weights are applied with # einsum so the per-source arithmetic is independent of the # number of sources. arrays = self._get_batch_arrays() hist = self._threaded_batch( batch_perimeter, self._batch_bbox_args(), mask=arrays['mask'], segm=arrays['segm']) perimeter = np.einsum('nk,k->n', hist.astype(float), weights) perimeter[self._all_masked] = np.nan return np.array(perimeter) * u.pix @property @deprecated('3.1', until='4.0') def inertia_tensor(self): """ The inertia tensor of the source for the rotation around its center of mass. .. deprecated:: 3.1 The inertia tensor carries no information beyond the `covariance` matrix. Use the `covariance` matrix and the shape properties that are derived from it instead. The rows and columns of the ``(2, 2)`` tensor are ordered ``(y, x)``, the reverse of the `covariance` matrix. The diagonal elements are the moments of inertia about the y- and x-axes and the off-diagonal elements are the negative of the product of inertia. The tensor is not normalized by the total flux. """ moments = self._array('moments_central') return inertia_tensor_from_moments(moments) * u.pix**2 @cached_property def _singular_covariance_mask(self): """ A boolean mask with a leading source axis that is `True` for sources whose raw covariance matrix is singular or nearly singular. A source is flagged when its minor-axis variance (the smaller eigenvalue of its raw, unregularized covariance matrix) is less than ``PIXEL_VARIANCE`` (``1 / 12``), the variance of a uniform distribution across a single pixel. This includes point-like sources and thin sources that are unresolved along only one axis. Sources with a NaN covariance are not flagged. These sources get the ``'singular_covariance'`` flag, and they are exactly the ones whose covariance is regularized. A source whose covariance is not positive semidefinite is not flagged here. Its covariance is NaN and it gets the ``'undefined_shape'`` flag instead. The flag matches the equivalent aperture flag (see `~photutils.aperture.decode_aperture_flags`). """ return is_singular_covariance(self._raw_covariance, determinant=self._raw_covariance_det) @cached_property def _raw_covariance_det(self): """ The determinant of the raw ``(N, 2, 2)`` covariance matrix. It is computed once and shared by `_covariance`, `_singular_covariance_mask`, and the invalid-covariance test in `flags`. """ return covariance_determinant(self._raw_covariance) @cached_property def _raw_covariance(self): """ The raw ``(N, 2, 2)`` covariance matrix of the 2D Gaussian function that has the same normalized second-order moments as the source, before any regularization. This unregularized matrix is shared by `_covariance` (which regularizes a copy), by ``_singular_covariance_mask`` (which tests it for singularity), and by the invalid-covariance test in `flags`. Callers that modify the matrix in place must operate on a copy so the cached value is not corrupted. """ return covariance_from_moments(self._array('moments_central')) @cached_property @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. """ return regularize_covariance( self._raw_covariance, determinant=self._raw_covariance_det) @cached_property @use_detcat def covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source. The variance along each principal axis is at least ``1/12`` pixel**2 (to within floating-point rounding), the variance of a uniform distribution across a single pixel. For a source that is unresolved along an axis (e.g., a point-like or a very thin source), the variance along that axis is raised to ``1/12`` while the orientation and the variance along a resolved axis are unchanged. A source that is unresolved along both axes is isotropic, with an orientation of zero. The covariance is NaN if the net flux of the source is not positive (e.g., a completely masked source or a source with no positive data values) or if the second-order moments are not positive semidefinite. """ return self._covariance * (u.pix**2) @cached_property @use_detcat def covariance_eigvals(self): """ The two eigenvalues of the `covariance` matrix in decreasing order. """ return eigvals_from_covariance(self._covariance) * u.pix**2 @cached_property @use_detcat 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._array('covariance_eigvals') # This matches SourceExtractor's A parameter return np.sqrt(eigvals[:, 0]) @cached_property @use_detcat 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._array('covariance_eigvals') # This matches SourceExtractor's B parameter return np.sqrt(eigvals[:, 1]) @cached_property @use_detcat 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)) @cached_property @use_detcat 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 counterclockwise direction and is in the range (-90, 90] degrees. """ return orientation_from_covariance(self._covariance) * u.deg @cached_property @use_detcat def sky_orientation(self): """ The position angle on the sky of the major axis of the 2D Gaussian function that has the same second-order moments as the source. The pixel `covariance` matrix is transported to the local tangent plane with the WCS Jacobian evaluated at each source `centroid`, which accounts for the WCS rotation, parity, and any local distortion. The position angle is measured from North toward East (i.e., counterclockwise on the sky) in the celestial frame of the input ``wcs`` and is in the range (-90, 90] degrees. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects sky_cov = pixel_to_sky_covariance(self.wcs, self._covariance, self._array('centroid')) return sky_orientation_from_covariance(sky_cov) * u.deg @cached_property @use_detcat 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)) @cached_property @use_detcat 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 @cached_property @use_detcat def ellipticity(self): r""" 1.0 minus the ratio of the lengths of the semiminor and semimajor 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) @cached_property @use_detcat 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 @cached_property @use_detcat 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 @cached_property @use_detcat def covariance_xy(self): r""" The ``(0, 1)`` and ``(1, 0)`` elements of the `covariance` matrix, representing :math:`\sigma_{xy}` (the covariance of ``x`` and ``y``), in units of pixel**2. """ return self._covariance[:, 0, 1] * u.pix**2 @cached_property @use_detcat 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) @cached_property @use_detcat 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) @cached_property @use_detcat 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))) @cached_property @use_detcat 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 the :math:`n` pixel values :math:`x_i` are sorted in order of increasing absolute value and :math:`\overline{|x|}` is the mean of their absolute values. If all pixel values are 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. The Gini coefficient is NaN for a completely masked source. """ return _batch_gini(self._data_values) @cached_property 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) width_in = (bbox_.ixmax - bbox_.ixmin) * _LOCAL_BKG_SCALE width_out = width_in + 2 * self.local_bkg_width height_in = (bbox_.iymax - bbox_.iymin) * _LOCAL_BKG_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 @cached_property def local_background_aperture(self): """ The `~photutils.aperture.RectangularAnnulus` aperture used to estimate the local background. `None` if ``local_bkg_width`` is zero. Returns a list of apertures for multisource catalogs, or a single aperture for a single-source catalog. """ return self._local_background_apertures @cached_property def _local_background_batch(self): """ The local background kernel output. A tuple of the local background value (per pixel) and the number of usable annulus pixels of each source. Both are zero if ``local_bkg_width`` is zero. """ if self.local_bkg_width == 0: return (np.zeros(self.n_labels), np.zeros(self.n_labels, dtype=np.intp)) arrays = self._get_batch_arrays() iymin, iymax, ixmin, ixmax = self._get_batch_bboxes() per_source = {'bbox_iymin': iymin, 'bbox_iymax': iymax, 'bbox_ixmin': ixmin, 'bbox_ixmax': ixmax} return self._threaded_batch( batch_local_background, per_source, merge=_concatenate_arrays, data=arrays['data'], mask=arrays['mask'], segm=arrays['segm'], width=self.local_bkg_width, scale=_LOCAL_BKG_SCALE, sigma=3.0, maxiters=20, min_pixels=_LOCAL_BKG_MIN_PIXELS) @cached_property def _local_background(self): """ The local background value (per pixel) of each source (see `local_background`). This property is always an `~numpy.ndarray` without units. """ local_bkgs = self._local_background_batch[0].copy() local_bkgs[self._all_masked] = np.nan return local_bkgs @cached_property def _local_bkg_n_pixels(self): """ The number of usable local background annulus pixels of each source. """ return self._local_background_batch[1] @cached_property 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 nonfinite, and within any nonzero pixel label in the segmentation image. The value is the `~photutils.background.SExtractorBackground` estimate of the usable annulus pixel values with a ``SigmaClip(sigma=3.0, cenfunc='median', maxiters=20)`` sigma clip, matching it to within rounding. It is zero for a source with fewer than 10 usable annulus pixels, which sets the ``local_bkg_too_few_pixels`` flag (see `flags`). The sources are measured concurrently when ``n_threads`` > 1. The local background is zero if ``local_bkg_width`` is zero. It is NaN for a source that is completely masked (for any ``local_bkg_width``). """ bkg = self._local_background if self._data_unit is not None: bkg <<= self._data_unit return bkg def _make_circular_apertures(self, radius): """ Make a 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. """ x_centroid = np.atleast_1d(self.x_centroid) y_centroid = np.atleast_1d(self.y_centroid) radius = np.broadcast_to(radius, len(x_centroid)) if np.any(radius <= 0): msg = 'radius must be > 0' raise ValueError(msg) apertures = [] for (xcen, ycen, radius_, all_masked) in zip(x_centroid, 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 def make_circular_apertures(self, radius): """ Make a 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 or 1D `~numpy.ndarray` The radius of the circle in pixels, either a single value or one value per source. 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_scalar(self._make_circular_apertures(radius)) @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 self._make_scalar(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 attributes ``[name]_flux`` and ``[name]_flux_err`` if they exist. Returns ------- flux, flux_err : float, `~numpy.ndarray`, or \ `~astropy.units.Quantity` The aperture fluxes and flux errors, in the units of the input ``data``. 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 ``error`` was not input, then the flux errors are NaN. """ if radius <= 0: msg = 'radius must be > 0' raise ValueError(msg) flux, flux_err = self._calc_circular_photometry(radius) 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, min_circ_radius): """ 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 the scaled axes of a source are zero (its ``scale`` is zero due to a minimum circular radius set in ``kron_params``) then a circular aperture with radius ``min_circ_radius`` will be returned for that source. Parameters ---------- scale : float or `~numpy.ndarray` The scale factor to apply to the ellipse major and minor axes. A `~numpy.ndarray` input must be a 1D array of length ``n_labels``. min_circ_radius : float The radius in pixels of the circular aperture that is returned for a source whose scaled axes are zero. The scaled axes are zero only for a positive minimum circular radius, so a value of zero never makes an aperture. Returns ------- result : list of `~photutils.aperture.PixelAperture` A list of `~photutils.aperture.EllipticalAperture` or `~photutils.aperture.CircularAperture` instances. The aperture will be `None` where the source `centroid` position or scaled elliptical shape parameters are not finite or where the source is completely masked. """ xcen = np.atleast_1d(self.x_centroid) ycen = np.atleast_1d(self.y_centroid) major_size = np.atleast_1d(self.semimajor_axis.value) * scale minor_size = np.atleast_1d(self.semiminor_axis.value) * scale theta = np.atleast_1d(self.orientation.to_value(u.radian)) 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=min_circ_radius)) continue (xcen_, ycen_, major_, minor_, theta_) = values[:-1] aperture.append(EllipticalAperture((xcen_, ycen_), major_, minor_, theta=theta_)) return aperture @cached_property @use_detcat def _kron_radius_sums(self): """ The numerator and denominator sums of the first-moment Kron radius, as an array of shape ``(n_labels, 2)``. The sums are NaN for sources with no measured Kron radius. """ scale = 6.0 xcen = np.ascontiguousarray(self._array('x_centroid'), dtype=np.float64) ycen = np.ascontiguousarray(self._array('y_centroid'), dtype=np.float64) semimajor = np.ascontiguousarray( self._array('semimajor_axis').value, dtype=np.float64) semiminor = np.ascontiguousarray( self._array('semiminor_axis').value, dtype=np.float64) theta = np.ascontiguousarray( self._array('orientation').to_value(u.radian), dtype=np.float64) cxx = np.ascontiguousarray(self._array('ellipse_cxx').value, dtype=np.float64) cxy = np.ascontiguousarray(self._array('ellipse_cxy').value, dtype=np.float64) cyy = np.ascontiguousarray(self._array('ellipse_cyy').value, dtype=np.float64) # Completely masked sources and sources with a nonfinite # centroid or shape have no measured Kron radius (NaN) skip = (self._all_masked | ~np.isfinite(xcen) | ~np.isfinite(ycen) | ~np.isfinite(semimajor * scale) | ~np.isfinite(semiminor * scale) | ~np.isfinite(theta)).astype(np.uint8) min_circ_radius = (self.kron_params[2] if len(self.kron_params) == 3 else 0.0) arrays = self._get_batch_arrays() return self._threaded_batch( batch_kron_radius, {'labels': self._batch_labels(), 'xcen': xcen, 'ycen': ycen, 'semimajor': semimajor, 'semiminor': semiminor, 'theta': theta, 'cxx': cxx, 'cxy': cxy, 'cyy': cyy, 'skip': skip}, data=arrays['data'], mask=arrays['mask'], segm=arrays['segm'], seg_method=SEG_METHOD_CODES[self.aperture_mask_method], scale=scale, min_circ_radius=min_circ_radius, max_aper_size=max(self._data.size, 1_000_000)) @cached_property @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, except that it is the minimum Kron radius where the Kron numerator or denominator is not positive. """ flux_numer, flux_denom = self._kron_radius_sums.T kron_min = self.kron_params[1] # Ignore RuntimeWarning from undefined (NaN) sums and from a # zero denominator with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) kron_radius = flux_numer / flux_denom # Set the Kron radius to the minimum Kron radius if the # numerator or denominator is not positive (NaN comparisons # are False, so undefined sources stay NaN). kron_radius[(flux_numer <= 0) | (flux_denom <= 0)] = kron_min return kron_radius 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 with pixel units and one value per source, also for a scalar catalog. """ 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 @cached_property @use_detcat def kron_radius_measured(self): r""" The measured *unscaled* first-moment Kron radius. This is the first-moment Kron radius as measured from the data (see `kron_radius` for the definition), before any of the ``kron_params`` minimum values are applied. It is not multiplied by the ``kron_params[0]`` scaling parameter. The `kron_radius` property is the value used to define the Kron aperture. The two differ for sources that have the ``kron_minimum_radius`` flag set, where `kron_radius` is either the minimum unscaled Kron radius (``kron_params[1]``) or zero (if the minimum circular aperture is used). They also differ where the measured value exceeds 6.0 (the measurement aperture scale factor). The measured value is returned here, while `kron_radius` is ``np.nan``. If either the numerator or denominator of the first-moment Kron radius is less than or equal to 0, then the measured Kron radius is undefined and ``np.nan`` will be returned. If the source is completely masked, then ``np.nan`` will be returned. If a ``detection_catalog`` was input to `SourceCatalog`, then its ``kron_radius_measured`` will be returned. """ kron_radius = self._measured_kron_radius.copy() flux_numer, flux_denom = self._kron_radius_sums.T kron_radius[(flux_numer <= 0) | (flux_denom <= 0)] = np.nan return kron_radius << u.pix @cached_property @use_detcat def kron_radius(self): r""" The *unscaled* first-moment Kron radius used to define the Kron aperture. The measured *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 unscaled, meaning that it is not multiplied by the ``kron_params[0]`` scaling parameter. It is the measured value given above after the following adjustments are applied. The measured value without these adjustments is available in `kron_radius_measured`. * If the measured value is less than the minimum unscaled Kron radius (``kron_params[1]``), or if either the numerator or denominator above is less than or equal to 0, then `kron_radius` is set to the minimum unscaled Kron radius. * 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 `kron_radius` is set to zero and the Kron aperture will be a circle with this minimum radius. * If the measured value exceeds 6.0 (the measurement aperture scale factor), then `kron_radius` is set to ``np.nan``. Such values are unphysical, typically caused by near-cancellation in the denominator of the Kron formula due to outlier pixels or noise. Sources affected by either of the first two adjustments have the ``kron_minimum_radius`` flag set in `flags`. 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 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] min_circ_radius = kron_params[2] if len(kron_params) == 3 else 0.0 return self._make_elliptical_apertures( scale=scale, min_circ_radius=min_circ_radius) @cached_property @use_detcat 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, elliptical shape parameters, or `kron_radius` 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 multisource catalogs, or a single aperture for a single-source catalog. """ return self._make_kron_apertures(self.kron_params) @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, elliptical shape parameters, or `kron_radius` are not finite or where the source is completely masked. """ if kron_params is None: return self.kron_aperture kron_params = self._validate_kron_params(kron_params) return self._make_scalar(self._make_kron_apertures(kron_params)) @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, elliptical shape parameters, or `kron_radius` 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 : `~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. """ if kron_params is None: apertures = self._array('kron_aperture') else: kron_params = self._validate_kron_params(kron_params) 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 self._make_scalar(patches) @staticmethod def _overlap_params(kwargs): """ Translate aperture ``to_mask`` keyword arguments to the ``(use_exact, subpixels)`` overlap parameters of the batch drivers. Parameters ---------- kwargs : dict The ``to_mask`` keyword arguments (``method`` and, for the subpixel method, ``subpixels``). Returns ------- use_exact, subpixels : int The overlap parameters. """ method = kwargs.get('method', 'exact') if method == 'exact': return 1, 1 if method == 'center': return 0, 1 return 0, kwargs.get('subpixels', 5) def _calc_circular_photometry(self, radius): """ Calculate the flux and flux error (without units) in a circular aperture of the given radius centered on each source centroid. See the `SourceCatalog` ``aperture_mask_method`` keyword for options to mask neighboring sources. If ``detection_catalog`` is input, then its `centroid` values will be used. Parameters ---------- radius : float The radius of the circle in pixels. Returns ------- flux, flux_err : 1D `~numpy.ndarray` The aperture flux and flux error arrays. NaN where the source is completely masked, its centroid is not finite, the aperture does not overlap the data or has no contributing pixels, or the aperture is unreasonably large. """ xcen = np.atleast_1d(self.x_centroid).astype(float) ycen = np.atleast_1d(self.y_centroid).astype(float) n_src = len(xcen) flux = np.full(n_src, np.nan) flux_err = np.full(n_src, np.nan) # Exclude undefined apertures (completely masked sources and # nonfinite centroids) and apertures whose bounding box would # be unreasonably large (out-of-memory guard) max_size = max(self._data.size, 1_000_000) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) nx = (np.floor(xcen + radius + 1.5) - np.floor(xcen - radius + 0.5)) ny = (np.floor(ycen + radius + 1.5) - np.floor(ycen - radius + 0.5)) valid = (~self._all_masked & np.isfinite(xcen) & np.isfinite(ycen) & np.isfinite(radius) & (nx * ny <= max_size)) idx = np.flatnonzero(valid) if idx.size == 0: return flux, flux_err arrays = self._get_batch_arrays() seg_code = SEG_METHOD_CODES[self.aperture_mask_method] seg_arr = None seg_labels = None if seg_code != 0: seg_arr = arrays['segm'] seg_labels = np.ascontiguousarray( np.atleast_1d(self.labels), dtype=np.intp)[idx] positions = np.ascontiguousarray( np.column_stack((xcen[idx], ycen[idx]))) local_bkg = np.ascontiguousarray(self._local_background, dtype=np.float64)[idx] use_exact, subpixels = self._overlap_params( self._aperture_mask_kwargs['circ']) result = self._threaded_batch( batch_aperture_sums, {'positions': positions, 'labels': seg_labels, 'local_bkg': local_bkg}, merge=_concatenate_aperture_sums, data=arrays['data'], error=arrays['error'], mask=arrays['mask'], shape_code=SHAPE_CIRCLE, params=np.array([radius], dtype=np.float64), ext_x=radius, ext_y=radius, off_x=0.0, off_y=0.0, use_exact=use_exact, subpixels=subpixels, segmentation=seg_arr, seg_method=seg_code, emit_sum=0) sums = result.sums sum_vars = result.sum_vars overlap = result.overlap fcounts = result.flag_counts # A pixel is a member if it is inside the aperture and not # excluded by the input mask, nonfinite data, or the # segmentation masking. Under 'correct', uncorrectable neighbor # pixels stay members (with value zero). members = fcounts[:, FLAG_COL_VALID].copy() if seg_code == 4: members += fcounts[:, FLAG_COL_UNCORRECTED] good = overlap & (members > 0) flux[idx] = np.where(good, sums, np.nan) if self._error is not None: flux_err[idx] = np.where(good, np.sqrt(sum_vars), np.nan) return flux, flux_err def _kron_aperture_params(self, kron_params): """ Return the Kron aperture geometry of each source as arrays. This is the array form of the apertures made by ``_make_kron_apertures`` (see `kron_aperture`), without constructing aperture objects. Parameters ---------- kron_params : tuple The validated Kron parameters. Returns ------- xcen, ycen : 1D `~numpy.ndarray` The aperture centers. major, minor : 1D `~numpy.ndarray` The semimajor and semiminor axes of the elliptical apertures (the scaled Kron radius times the isophotal axes). theta : 1D `~numpy.ndarray` The ellipse orientation in radians. circular : 1D `~numpy.ndarray` (bool) `True` where the aperture is instead the circle with the minimum circular radius ``kron_params[2]`` (i.e., where the Kron radius is zero). defined : 1D `~numpy.ndarray` (bool) `False` where the aperture is undefined (`None` in `kron_aperture`). The aperture is undefined where the source is completely masked or its centroid, elliptical shape parameters, or Kron radius are not finite. """ # NOTE: if kron_radius = NaN, scale = NaN and the aperture is # undefined kron_radius = self._calc_kron_radius(kron_params) scale = kron_radius.value * kron_params[0] xcen = np.atleast_1d(self.x_centroid) ycen = np.atleast_1d(self.y_centroid) major = np.atleast_1d(self.semimajor_axis.value) * scale minor = np.atleast_1d(self.semiminor_axis.value) * scale theta = np.atleast_1d(self.orientation.to_value(u.radian)) defined = (~self._all_masked & np.isfinite(xcen) & np.isfinite(ycen) & np.isfinite(major) & np.isfinite(minor) & np.isfinite(theta)) # kron_radius = 0 -> scale = 0 -> major/minor = 0 circular = defined & (major == 0) & (minor == 0) return xcen, ycen, major, minor, theta, circular, defined 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 undefined (`None` in `kron_aperture`) or the source is completely masked in this catalog, then ``np.nan`` will be returned and the ``kron_undefined`` flag is set. If ``detection_catalog`` is input, then its `centroid` values will be used. Parameters ---------- kron_params : tuple or `None`, optional The Kron parameters. If `None`, the apertures are those of `kron_aperture` (from the detection catalog, if input). Returns ------- kron_flux, kron_flux_err, kron_flags : tuple of `~numpy.ndarray` The Kron flux, flux error, and bitwise quality flags (see `~photutils.segmentation.decode_segmentation_flags`). """ if kron_params is None: # The geometry of the kron_aperture property, which is # taken from the detection catalog if input source = (self if self._detection_catalog is None else self._detection_catalog) kron_params = source.kron_params else: source = self kron_params = self._validate_kron_params(kron_params) (xcen, ycen, major, minor, theta, circular, defined) = source._kron_aperture_params(kron_params) # A source that is completely masked in this catalog has no # Kron photometry, also where the detection catalog defines its # aperture defined = defined & ~self._all_masked n_src = len(xcen) flux = np.full(n_src, np.nan) flux_err = np.full(n_src, np.nan) kron_flags = np.zeros(n_src, dtype=int) kron_flags[~defined] = SEGMENTATION_FLAGS.KRON_UNDEFINED # Exclude apertures whose bounding box would be unreasonably # large (out-of-memory guard) max_size = max(self._data.size, 1_000_000) circ_radius = kron_params[2] if len(kron_params) == 3 else 0.0 # Ignore RuntimeWarning from nonfinite values of undefined # apertures with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) cos_t = np.cos(theta) sin_t = np.sin(theta) x_ext = np.sqrt((major * cos_t) ** 2 + (minor * sin_t) ** 2) y_ext = np.sqrt((major * sin_t) ** 2 + (minor * cos_t) ** 2) x_ext = np.where(circular, circ_radius, x_ext) y_ext = np.where(circular, circ_radius, y_ext) nx = (np.floor(xcen + x_ext + 1.5) - np.floor(xcen - x_ext + 0.5)) ny = (np.floor(ycen + y_ext + 1.5) - np.floor(ycen - y_ext + 0.5)) oversized = defined & (nx * ny > max_size) kron_flags[oversized] = SEGMENTATION_FLAGS.KRON_UNDEFINED usable = defined & ~oversized # Group the apertures by shape for the batch driver circ_idx = np.flatnonzero(usable & circular) ell_idx = np.flatnonzero(usable & ~circular) circ_params = np.full((len(circ_idx), 1), circ_radius) ell_params = np.column_stack((major, minor, theta))[ell_idx] positions = np.column_stack((xcen, ycen)).astype(float) arrays = self._get_batch_arrays() seg_code = SEG_METHOD_CODES[self.aperture_mask_method] seg_arr = arrays['segm'] if seg_code != 0 else None labels_arr = self._batch_labels() local_bkg = np.ascontiguousarray(self._local_background, dtype=np.float64) has_error = self._error is not None for idx, psrc, shape_code in ( (circ_idx, circ_params, SHAPE_CIRCLE), (ell_idx, ell_params, SHAPE_ELLIPSE)): if idx.size == 0: continue psrc = np.ascontiguousarray(psrc, dtype=np.float64) pos = np.ascontiguousarray(positions[idx]) seg_labels = labels_arr[idx] if seg_code != 0 else None result = self._threaded_batch( batch_aperture_sums, {'positions': pos, 'labels': seg_labels, 'local_bkg': local_bkg[idx], 'params_per_source': psrc}, merge=_concatenate_aperture_sums, data=arrays['data'], error=arrays['error'], mask=arrays['mask'], shape_code=shape_code, params=None, ext_x=0.0, ext_y=0.0, off_x=0.0, off_y=0.0, use_exact=1, subpixels=1, segmentation=seg_arr, seg_method=seg_code, emit_sum=0) sums = result.sums sum_vars = result.sum_vars overlap = result.overlap fcounts = result.flag_counts weights_out = result.weights_out n_pix = fcounts[:, FLAG_COL_N_PIXELS] clipped = fcounts[:, FLAG_COL_BBOX_CLIPPED].astype(bool) weights_out = weights_out.astype(bool) # A pixel is a member if it is inside the aperture and not # excluded by the input mask, nonfinite data, or the # segmentation masking. Under 'correct', uncorrectable # neighbor pixels stay members (with value zero), so they # keep the flux at 0.0 rather than NaN. members = fcounts[:, FLAG_COL_VALID].copy() if seg_code == 4: members += fcounts[:, FLAG_COL_UNCORRECTED] good = overlap & (members > 0) flux[idx] = np.where(good, sums, np.nan) if has_error: flux_err[idx] = np.where(good, np.sqrt(sum_vars), np.nan) grp_flags = np.zeros(len(idx), dtype=int) no_ovl = ~overlap | (clipped & (n_pix == 0)) grp_flags[no_ovl] |= SEGMENTATION_FLAGS.KRON_NO_OVERLAP grp_flags[(n_pix > 0) & weights_out] |= ( SEGMENTATION_FLAGS.KRON_PARTIAL_OVERLAP) grp_flags[fcounts[:, FLAG_COL_MASKED] > 0] |= ( SEGMENTATION_FLAGS.KRON_MASKED_PIXELS) grp_flags[fcounts[:, FLAG_COL_NONFINITE_DATA] > 0] |= ( SEGMENTATION_FLAGS.KRON_NON_FINITE_DATA) grp_flags[fcounts[:, FLAG_COL_NONFINITE_ERROR] > 0] |= ( SEGMENTATION_FLAGS.KRON_NON_FINITE_ERROR) seg_any = (fcounts[:, FLAG_COL_SEG] + fcounts[:, FLAG_COL_SEG_MASKED]) > 0 grp_flags[seg_any] |= ( SEGMENTATION_FLAGS.KRON_NEIGHBOR_PIXELS) unc_any = (fcounts[:, FLAG_COL_UNCORRECTED] + fcounts[:, FLAG_COL_UNCORRECTED_MASKED]) > 0 grp_flags[unc_any] |= ( SEGMENTATION_FLAGS.KRON_UNCORRECTED_PIXELS) kron_flags[idx] |= grp_flags return flux, flux_err, kron_flags @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 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 attributes ``[name]_flux`` and ``[name]_flux_err`` if they exist. Returns ------- flux, flux_err : float, `~numpy.ndarray`, or \ `~astropy.units.Quantity` The aperture fluxes and flux errors, in the units of the input ``data``. NaN will be returned where the aperture is `None` (e.g., where the source `centroid` position, elliptical shape parameters, or `kron_radius` are not finite or where the source is completely masked). NaN is also returned where the aperture contains no unmasked pixels within the data. If ``error`` was not input, then the flux errors are NaN. """ 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 @cached_property def _kron_photometry(self): """ The flux, flux error, and bitwise quality flags of the Kron aperture (without units) as a 2D array with columns ``(flux, flux_err, flags)`` and shape ``(n_labels, 3)``. The flags are stored as floats so that all three values can be kept in a single array that slices along the source axis. All of the flag bit values are exactly representable as floats. 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 for the flux and flux error. 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)) @cached_property def _kron_flags(self): """ The per-source bitwise quality flags from the Kron-aperture photometry. """ return self._kron_photometry[:, 2].astype(int) @cached_property 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, elliptical shape parameters, or `kron_radius` are not finite or where the source is completely masked. NaN is also returned where the aperture contains no unmasked pixels within the data. """ kron_flux = self._kron_photometry[:, 0] if self._data_unit is not None: kron_flux <<= self._data_unit return kron_flux @cached_property 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, elliptical shape parameters, or `kron_radius` are not finite or where the source is completely masked. NaN is also returned where the aperture contains no unmasked pixels within the data. If ``error`` was not input, then the errors are NaN. The error is the quadrature sum of the input ``error`` values over the pixels in the Kron aperture, each weighted by its aperture overlap fraction, with the aperture treated as fixed. The aperture center, shape, and radius are themselves measured from the data, and the flux variation from that aperture uncertainty is not propagated. The error is therefore a lower bound. The missing contribution is negligible when the aperture extends into the sky background, where the source surface brightness at the aperture boundary is small. """ kron_flux_err = self._kron_photometry[:, 1] if self._data_unit is not None: kron_flux_err <<= self._data_unit return kron_flux_err @cached_property @use_detcat def _max_circular_kron_radius(self): """ The maximum circular Kron radius used as the upper limit of ``flux_radius``. """ semimajor_sig = self._array('semimajor_axis').value kron_radius = self._array('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] return radius @cached_property def _flux_radius_optimizer_args(self): """ The prepared per-source inputs of the flux-radius root-find (see ``batch_flux_radius_prepare``), as a `~photutils.segmentation._batch_results.BatchFluxRadiusArgs` with one entry per source. The pixel values, mask, local background, and Kron flux are those of this catalog. If a ``detection_catalog`` was input, the centroids and the maximum radii are those of the detection catalog. A source that cannot have a solution has a zero pixel count. This cached property snapshots the current ``_aperture_mask_kwargs['flux_radius']`` settings. Changing them (e.g., via ``_set_semode``) after the first flux_radius call has no effect. The preparation is multithreaded when ``n_threads`` > 1. """ kron_flux = np.ascontiguousarray(self._kron_photometry[:, 0], dtype=np.float64) # unitless max_radius = np.ascontiguousarray(self._max_circular_kron_radius, dtype=np.float64) x_centroid = np.ascontiguousarray(np.atleast_1d(self.x_centroid), dtype=np.float64) y_centroid = np.ascontiguousarray(np.atleast_1d(self.y_centroid), dtype=np.float64) local_bkg = np.ascontiguousarray(self._local_background, dtype=np.float64) use_exact, subpixels = self._overlap_params( self._aperture_mask_kwargs['flux_radius']) # Sources with a nonfinite centroid, Kron flux, or maximum # radius, or a zero Kron flux, have no solution skip = (~np.isfinite(x_centroid) | ~np.isfinite(y_centroid) | ~np.isfinite(kron_flux) | ~np.isfinite(max_radius) | (kron_flux == 0)).astype(np.uint8) arrays = self._get_batch_arrays() return self._threaded_batch( batch_flux_radius_prepare, {'labels': self._batch_labels(), 'xcen': x_centroid, 'ycen': y_centroid, 'local_bkg': local_bkg, 'kronflux': kron_flux, 'max_radius': max_radius, 'skip': skip}, merge=_concatenate_flux_radius_args, data=arrays['data'], mask=arrays['mask'], segm=arrays['segm'], seg_method=SEG_METHOD_CODES[self.aperture_mask_method], use_exact=use_exact, subpixels=subpixels, max_aper_size=max(self._data.size, 1_000_000)) @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``. The radius is measured in the ``data`` of this catalog, after subtracting its local background, in circular apertures centered on the source `centroid`. It encloses the given fraction of the `kron_flux` of this catalog. The largest radius that is searched is the semimajor axis of the Kron aperture (or its radius for a circular Kron aperture). If a ``detection_catalog`` was input to `SourceCatalog`, the radius is still measured in the ``data`` of this catalog, with its local background, as a fraction of its own `kron_flux`. Only the source `centroid` and the Kron aperture, which define the aperture centers and the largest radius, are taken from the detection catalog. The radius is therefore not the ``flux_radius`` of the detection catalog. 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 : `~astropy.units.Quantity` The circular radius, in pixels, that encloses the specified fraction of the Kron flux. A scalar is returned for a single-source catalog. NaN is returned where no solution was found or where the Kron flux is zero or nonfinite. """ 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 self._make_scalar(result) args = self._flux_radius_optimizer_args radius = self._threaded_batch( batch_flux_radius_solve, {'starts': args.starts, 'counts': args.counts, 'nx': args.nx, 'ny': args.ny, 'grid_edges': args.grid_edges, 'kronflux': args.kronflux, 'max_radius': args.max_radius}, packed={'values': args.values}, fraction=fraction, use_exact=args.use_exact, subpixels=args.subpixels) result = radius << u.pix self._flux_radius_cache[fraction] = result if name is not None: self.add_property(name, result, overwrite=overwrite) return self._make_scalar(result) 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`, optional 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``. ``fill_value`` will be changed to have the same ``dtype`` as the ``array`` array, with one exception. If ``array`` 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(np.atleast_1d(self.x_centroid), np.atleast_1d(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 self._make_scalar(cutouts) def __getattr__(name): # DEFAULT_COLUMNS is a deprecated public name for the 3.0 default # to_table() columns, which did not include the flags column. if name == 'DEFAULT_COLUMNS': msg = ('DEFAULT_COLUMNS is deprecated and will be removed in ' 'version 4.0. Use the SourceCatalog.default_columns attribute ' 'instead.') warnings.warn(msg, PhotutilsDeprecationWarning, stacklevel=2) return ['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'] msg = f'module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) astropy-photutils-24be529/photutils/segmentation/core.py000066400000000000000000003410611526452531300236470ustar00rootroot00000000000000# 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 from functools import cached_property import numpy as np from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import find_objects, grey_dilation from photutils.aperture import BoundingBox from photutils.aperture.region_converters import _shapely_polygon_to_region from photutils.segmentation._label_stats import label_stats 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'] # The label measurement and the relabeling maps use arrays that are # indexed by the label value. An array whose largest label exceeds both # this value and a quarter of its number of pixels is instead handled # through the positions of its labels in the sorted labels, so that the # memory used does not depend on the size of the labels. The five # per-label arrays of the label measurement use at most 10 bytes per # pixel for an array above the minimum limit. _MIN_MAX_LABEL_LIMIT = 65536 # 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', } def _get_labels(array, *, return_counts=False): """ Return the sorted nonzero values in ``array``. Parameters ---------- array : `~numpy.ndarray` An array of label values. It may have any shape (e.g., a full segmentation array or already-extracted values). return_counts : bool, optional If `True`, also return the number of occurrences of each label. Returns ------- labels : `~numpy.ndarray` The sorted nonzero label values. `numpy.unique` preserves the input dtype. counts : `~numpy.ndarray`, optional The number of occurrences of each label, in the same order as ``labels``. Only returned if ``return_counts`` is `True`. """ return np.unique(array[array != 0], return_counts=return_counts) def _max_dense_label(n_pixels): """ Return the largest label of an array with ``n_pixels`` pixels that is handled with arrays indexed by the label value. """ return max(n_pixels // 4, _MIN_MAX_LABEL_LIMIT) def _get_label_stats(array): """ Return the labels of a segmentation array with their areas and bounding boxes. A 2D array is measured by compiled code without sorting. A 2D array with very large labels is first mapped to consecutive labels, which costs a sort of the labeled pixels, so that the memory used is set by the number of labels and not by their size. An array of another dimension, or one with negative labels, is measured with `numpy.unique`, which does not give the bounding boxes. Parameters ---------- array : int `~numpy.ndarray` The segmentation array. Returns ------- labels : 1D `~numpy.ndarray` The sorted nonzero values in ``array``, with its dtype. areas : 1D intp `~numpy.ndarray` The number of pixels of each label. bounds : 2D intp `~numpy.ndarray` or `None` The ``(ymin, ymax, xmin, xmax)`` bounds of the minimal bounding box of each label, one row per label, where the maximum bounds are exclusive. `None` if the bounding boxes were not measured. """ dtype = array.dtype max_label_limit = _max_dense_label(array.size) use_kernel = array.ndim == 2 and dtype.kind in 'iu' if use_kernel and dtype.kind == 'u' and dtype.itemsize == 8: # An unsigned 64-bit array is cast to int64 for the kernel, # which is safe only if its labels are below the limit. The # size check is needed because an empty array has no maximum. use_kernel = array.size > 0 and array.max() <= max_label_limit if use_kernel: # The kernel takes native int32 or int64 arrays. Other integer # arrays are cast to the smallest of the two that holds them. if not (dtype.isnative and dtype.type in (np.int32, np.int64)): fits_int32 = (dtype.itemsize < 4 or (dtype.kind == 'i' and dtype.itemsize == 4)) dtype = np.int32 if fits_int32 else np.int64 (_, _, counts, ymin, ymax, xmin, xmax) = label_stats( np.ascontiguousarray(array, dtype=dtype), max_label_limit) if counts is not None: idx = np.flatnonzero(counts) bounds = np.column_stack((ymin[idx], ymax[idx] + 1, xmin[idx], xmax[idx] + 1)) return idx.astype(array.dtype), counts[idx], bounds if array.ndim != 2: labels, areas = _get_labels(array, return_counts=True) return labels, areas, None # The sort that finds the labels also gives the position of the # label of every labeled pixel in the sorted labels mask = array != 0 labels, inverse, areas = np.unique(array[mask], return_inverse=True, return_counts=True) if labels.size == 0 or labels[0] < 0: return labels, areas, None # Replace every label by its position in the sorted labels (plus # one, keeping zero as the background) and measure that array compact = np.zeros(array.shape, dtype=np.int64) compact[mask] = inverse + 1 (_, _, _, ymin, ymax, xmin, xmax) = label_stats(compact, len(labels)) bounds = np.column_stack((ymin[1:], ymax[1:] + 1, xmin[1:], xmax[1:] + 1)) return labels, areas, bounds class _SparseRelabelMap: """ A map from label values to new label values that is indexed like the dense relabeling array, but that stores only the labels. A value that is not one of the labels, including the background, maps to zero. Parameters ---------- labels : 1D int `~numpy.ndarray` The sorted positive label values. new_labels : 1D int `~numpy.ndarray` The new value of each label. The values returned by the map have its dtype. """ def __init__(self, labels, new_labels): # numpy.concatenate returns arrays with the native byte order, # so the dtype of the new labels is kept for the output self._dtype = new_labels.dtype self._labels = np.concatenate((np.zeros(1, dtype=labels.dtype), labels)) self._new_labels = np.concatenate( (np.zeros(1, dtype=new_labels.dtype), new_labels)) def __getitem__(self, index): # The cast keeps the search in the integer dtype of the labels. # NumPy would compare mixed signed and unsigned 64-bit integers # as floats, which cannot represent every large label. The # index values must be representable in the dtype of the # labels, as the labels and the pixels of the array are. A # value that is not would wrap and is not detected. index = np.asarray(index).astype(self._labels.dtype, copy=False) idx = np.searchsorted(self._labels, index) idx = np.minimum(idx, len(self._labels) - 1) return np.where(self._labels[idx] == index, self._new_labels[idx], 0).astype(self._dtype, copy=False) def _make_relabel_map(labels, new_labels, n_pixels): """ Make the map from label values to new label values. Parameters ---------- labels : 1D int `~numpy.ndarray` The sorted positive label values. It must not be empty. new_labels : 1D int `~numpy.ndarray` The new value of each label. n_pixels : int The number of pixels of the segmentation array. Returns ------- relabel_map : 1D `~numpy.ndarray` or `_SparseRelabelMap` An object that returns the new values when it is indexed with label values. It is an array with one element per label value up to the largest label, or a `_SparseRelabelMap` for very large labels. """ max_label = int(labels[-1]) if max_label > _max_dense_label(n_pixels): return _SparseRelabelMap(labels, new_labels) relabel_map = np.zeros(max_label + 1, dtype=new_labels.dtype) relabel_map[labels] = new_labels return relabel_map def _remap_deblend_label_map(deblend_label_map, relabel_map): """ Return a new deblend label map with remapped child labels. Child labels that map to zero are dropped, as is a parent that has no child labels left. Child labels of a parent that map to the same new label are included once. Parameters ---------- deblend_label_map : dict The mapping of parent label numbers to arrays of deblended (child) label numbers. relabel_map : 1D `~numpy.ndarray` or `_SparseRelabelMap` The map from the original label numbers to the new label numbers (see ``_make_relabel_map``). Returns ------- result : dict A new mapping of the parent labels to their arrays of child labels translated through ``relabel_map``, in their original order. """ new_map = {} for parent_label, child_labels in deblend_label_map.items(): new_labels = relabel_map[child_labels] # Keep the first occurrence of each new label _, idx = np.unique(new_labels, return_index=True) new_labels = new_labels[np.sort(idx)] new_labels = new_labels[new_labels != 0] if new_labels.size > 0: new_map[parent_label] = new_labels return new_map def _remap_flags_map(flags_map, relabel_map): """ Return a new per-label flags map with remapped labels. Labels that map to zero are dropped. Labels that map to the same new label have their flags combined with a bitwise OR. Parameters ---------- flags_map : dict The mapping of label numbers to bitwise flag values. relabel_map : 1D `~numpy.ndarray` or `_SparseRelabelMap` The map from the original label numbers to the new label numbers (see ``_make_relabel_map``). Returns ------- result : dict A new mapping of the remapped label numbers to their combined flag values. """ new_map = {} for label, flags in flags_map.items(): new_label = int(relabel_map[label]) if new_label == 0: continue new_map[new_label] = new_map.get(new_label, 0) | flags return new_map 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. Attributes ---------- flags : `~numpy.ndarray` A 1D array of per-label bitwise quality flags, in the same order as the ``labels`` attribute. The flags record deblending provenance and are all zero for segmentation images not produced by :func:`~photutils.segmentation.deblend_sources`. Use :func:`~photutils.segmentation.decode_segmentation_flags` to decode the values. The flags are reset to zero when the ``data`` attribute is reassigned, and slicing returns a new instance with all flags reset to zero. info : dict A dictionary containing auxiliary information about the segmentation image. For example, segmentation images returned by :func:`~photutils.segmentation.deblend_sources` store the input labels affected by deblending warnings under ``'non_positive_min_labels'`` and ``'too_many_markers_labels'`` keys. The dictionary is empty if there is no auxiliary information. It is reset to an empty dictionary when the ``data`` attribute is reassigned. 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`. The key must be a tuple of two slices, and the sliced array must not be empty. """ def __init__(self, data): self.data = data @classmethod def _from_data(cls, data, *, labels=None, areas=None, slices=None, deblend_label_map=None): """ Create a `SegmentationImage` from a prevalidated segmentation array, optionally seeding cached properties. This bypasses the input validation performed by ``__init__`` and is used internally where the input array is already known to be a valid segmentation image (e.g., the outputs of `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources`). Parameters ---------- data : 2D int `~numpy.ndarray` The valid 2D segmentation array. labels : `~numpy.ndarray`, optional The sorted nonzero labels in ``data``, used to seed the ``labels`` cached property. areas : `~numpy.ndarray`, optional The pixel area of each label, in the same order as ``labels``, used to seed the ``areas`` cached property. slices : list of tuple of slice, optional The slices for each label, used to seed the ``slices`` cached property. The list order must match ``labels``. deblend_label_map : dict, optional The mapping of parent label numbers to deblended label numbers. If `None`, an empty mapping is used. Returns ------- segment_img : `SegmentationImage` The new `SegmentationImage` instance. """ segm = object.__new__(cls) segm._set_data(data, labels=labels, areas=areas, slices=slices) if deblend_label_map is not None: segm._deblend_label_map = deblend_label_map return segm 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 __setattr__(self, name, value): """ Set an attribute. The segmentation array is write protected because the derived cached properties must be reset whenever it changes. Use the ``data`` attribute or :meth:`_set_data` instead. """ if name == '_data': msg = ('Direct assignment to _data is not allowed. Assign ' 'to the data attribute or call _set_data()') raise AttributeError(msg) super().__setattr__(name, value) def __array__(self): """ Array representation of the segmentation array (e.g., for matplotlib). """ return self._data @cached_property def segments(self): """ A list of `Segment` objects. The background (label 0) is not included. 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 @cached_property def deblended_labels(self): """ A sorted 1D array of deblended label numbers. The array 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) # A label merged from the deblended labels of more than one # parent is listed under each parent return np.unique( np.concatenate(list(self._deblend_label_map.values()))) @cached_property 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. A label that was merged from the deblended labels of more than one parent (e.g., with :meth:`reassign_labels`) has only one of its parents here, the last one in the order of ``parent_to_deblended_labels``, which lists the label under each of its parents. 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 @cached_property 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 1D arrays of the deblended label numbers. Only deblended sources are included in the dictionary. A label that was merged from the deblended labels of more than one parent (e.g., with :meth:`reassign_labels`) is listed under each of its parents. The dictionary will be empty if deblending has not been performed or if no sources were deblended. """ return self._deblend_label_map @property def flags(self): """ A 1D array of per-label bitwise quality flags, in the same order as the ``labels`` attribute. The flags are set by `~photutils.segmentation.deblend_sources` to record deblending provenance. They are all zero for directly-constructed segmentation images and for the output of `~photutils.segmentation.detect_sources`. Use `~photutils.segmentation.decode_segmentation_flags` to decode the values. The flags are reset to zero when the ``data`` attribute is reassigned. Slicing the segmentation image (e.g., ``segm[10:50, 10:50]``) returns a new instance with all flags reset to zero as well. """ return np.array([self._flags_map.get(int(label), 0) for label in self.labels], dtype=int) @property def data(self): """ The segmentation array. """ return self._data @property def _cached_properties(self): """ A list of all class cached properties (even in superclasses). The result is cached on the class to avoid repeated introspection via `inspect.getmembers`. """ cls = self.__class__ attr = '_cached_properties_cache' # Subclasses get their own cached-property list if attr not in cls.__dict__: def is_cached_property(obj): return isinstance(obj, cached_property) setattr(cls, attr, [i[0] for i in inspect.getmembers( cls, predicate=is_cached_property)]) return getattr(cls, attr) def _reset_cached_properties(self): for key in self._cached_properties: self.__dict__.pop(key, None) def _set_data(self, data, *, labels=None, areas=None, slices=None, stats=None, preserve_info=False): """ Set the segmentation array and seed its derived properties. This is the only method that assigns the ``_data`` attribute. It performs no validation. The caller is responsible for passing a valid integer segmentation array. Any derived value that is not supplied is computed on first access. Supplied values are trusted and are not checked against ``data``. Parameters ---------- data : 2D int `~numpy.ndarray` The valid 2D segmentation array. labels : 1D int `~numpy.ndarray`, optional The sorted nonzero labels in ``data``. areas : 1D int `~numpy.ndarray`, optional The pixel area of each label, in the same order as ``labels``. slices : list of tuple of slice, optional The minimal bounding slices of each label, in the same order as ``labels``. stats : tuple, optional The ``(labels, areas, bounds)`` tuple returned by ``_get_label_stats`` for ``data``. preserve_info : bool, optional If `True`, keep the current ``info`` dictionary instead of resetting it to an empty one. This is used by the relabeling methods, where the change is an in-place relabeling rather than a data reassignment. """ if '_data' in self.__dict__: # Reset cached properties when data is reassigned, but not # on init self._reset_cached_properties() # Bypass __setattr__, which rejects direct _data assignment object.__setattr__(self, '_data', data) # Seed the known derived values. functools.cached_property # reads the instance dictionary first, so a seeded entry is # returned as-is and an absent one falls through to the # property body. for name, value in (('labels', labels), ('areas', areas), ('slices', slices), ('_label_stats', stats)): if value is not None: self.__dict__[name] = value # Reset the deblended label map, per-label flags map, and # auxiliary info explicitly since _deblend_label_map, # _flags_map, and info are regular attributes, not cached # properties cleared by _reset_cached_properties above. self.__dict__['_deblend_label_map'] = {} self.__dict__['_flags_map'] = {} if not preserve_info: self.__dict__['info'] = {} @data.setter def data(self, value): if not isinstance(value, np.ndarray): msg = 'Input data must be a numpy array' raise TypeError(msg) if isinstance(value, np.ma.MaskedArray): msg = 'Input data must not be a numpy masked array' raise TypeError(msg) if not np.issubdtype(value.dtype, np.integer): msg = 'data must have integer type' raise TypeError(msg) # The label measurement yields the sorted labels, their pixel # areas, and their bounding boxes stats = _get_label_stats(value) labels, areas, _ = stats # labels is sorted, so only the first element can be negative. # The size check also covers all-zero and zero-size arrays. if labels.size and labels[0] < 0: msg = 'The segmentation image cannot contain negative integers.' raise ValueError(msg) self._set_data(value, labels=labels, areas=areas, stats=stats) @cached_property 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) @cached_property def shape(self): """ The shape of the segmentation array. """ return self._data.shape @cached_property def _label_stats(self): """ The ``(labels, areas, bounds)`` tuple of the segmentation array (see ``_get_label_stats``). """ # Seeded by _set_data when the data attribute is assigned. This # runs only when the array was set without validation. return _get_label_stats(self._data) @cached_property def labels(self): """ The sorted nonzero labels in the segmentation array. """ # Normally seeded by _set_data. This runs only when the array # was set without known labels. return self._label_stats[0] @cached_property def n_labels(self): """ The number of nonzero labels in the segmentation array. """ return len(self.labels) @cached_property def max_label(self): """ The maximum label in the segmentation array. It is zero if the segmentation array has no labels. """ if self.n_labels == 0: return 0 return np.max(self.labels) def get_index(self, label): """ Find the index of the input ``label``. Array input is also accepted, in which case an array of indices is returned. :meth:`get_indices` delegates to this method. Parameters ---------- label : int or 1D array_like (int) The label number(s) to find. Returns ------- index : int or 1D int `~numpy.ndarray` The array index or indices. If ``label`` is a scalar, then the returned index will also be a scalar. 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 number(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. """ return self.get_index(labels) @cached_property def slices(self): """ A list of tuples, where each tuple contains two slices representing the minimal box that contains the labeled region. The background (label 0) is not included. The returned list has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ bounds = self._label_stats[2] if bounds is None: # find_objects raises an error for a zero-size array if self._data.size == 0: return [] return [slc for slc in find_objects(self._data) if slc is not None] return [(slice(ymin, ymax), slice(xmin, xmax)) for ymin, ymax, xmin, xmax in bounds.tolist()] def _get_slice(self, label): """ Return the bounding slice for a single label. The input ``label`` must already be known to be valid. Use :meth:`check_labels` first if that is not guaranteed. Parameters ---------- label : int The label number. Returns ------- slc : tuple of slice The minimal bounding slice containing the labeled region. """ # self.labels is sorted, so searchsorted gives the position in # the slices list, which matches the labels order return self.slices[np.searchsorted(self.labels, label)] @cached_property def bbox(self): """ A list of `~photutils.aperture.BoundingBox` objects, each the minimal bounding box that contains a labeled region. The list has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ if self._data.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] @cached_property def background_area(self): """ The area (in pixel**2) of the background (label=0) region. """ return self._data.size - np.count_nonzero(self._data) @cached_property def areas(self): """ A 1D array of areas (in pixel**2) of the nonzero labeled regions. The background (label 0) is not included. The returned array has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ # Normally seeded by _set_data from the same measurement that # produces the labels. This runs only when the array was set # without known areas. return self._label_stats[1] 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 nonzero. Returns ------- area : int The area of the labeled region. Raises ------ ValueError If ``label`` is invalid. """ 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. Each must be a label in the segmentation array. Returns ------- areas : `~numpy.ndarray` The areas of the labeled regions, in the same order as the input ``labels``. Raises ------ ValueError If any input ``labels`` are invalid. """ 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 or shapely is not available, ``slc`` is `None`, or no polygon was found. """ 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) # The raster of the label is one inside the label and zero # elsewhere, so that it does not depend on the label value label_data = label_mask.astype(np.uint8) raw_polys = list(shapes(label_data, connectivity=8, mask=label_mask, transform=transform)) geo_polys = [poly for poly, _ in raw_polys] 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. """ label = self._data.dtype.type(label) slc = self._get_slice(label) 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] @cached_property def is_consecutive(self): """ Boolean value indicating whether the nonzero 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) @cached_property def missing_labels(self): """ A 1D `~numpy.ndarray` of the sorted nonzero 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: # Convert numpy scalars to Python ints for a clean message bad_labels = sorted(int(label) for label in 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` or `None` The matplotlib colormap with colors in RGBA format. `None` is returned if the segmentation image has no labels. """ 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'). If `None`, the first color of the random colormap is kept. 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` or `None` The matplotlib colormap with colors in RGBA format. `None` is returned if the segmentation image has no labels. """ 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) @cached_property def cmap(self): """ A matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. It is `None` if the segmentation image has no labels. """ return self.make_cmap(background_color='#000000ff', seed=0) @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. Raises ------ ValueError If ``label`` is invalid, or if ``new_label`` is negative, is not an integer, or cannot be represented in the dtype of the segmentation array. Examples -------- >>> 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.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 number(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. Raises ------ ValueError If any input ``labels`` are invalid, or if ``new_label`` is negative, is not an integer, or cannot be represented in the dtype of the segmentation array. Examples -------- >>> 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.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) # The new label is zero or a label value that the dtype of the # segmentation array can hold valid = np.ndim(new_label) == 0 if valid: try: valid = int(new_label) == new_label except (TypeError, ValueError, OverflowError): valid = False if not valid or new_label < 0: msg = 'new_label must be a nonnegative integer' raise ValueError(msg) new_label = int(new_label) if new_label > np.iinfo(self.data.dtype).max: msg = (f'new_label {new_label} cannot be represented in the ' f'dtype of the segmentation array ({self.data.dtype})') raise ValueError(msg) labels = np.atleast_1d(labels) if labels.size == 0: # No label is reassigned, but the existing labels may not # be consecutive if relabel and self.n_labels > 0: self.relabel_consecutive() return # The new value of every label, in the original dtype new_labels = self.labels.astype(self.data.dtype, copy=True) new_labels[self.get_indices(labels)] = new_label # reassign labels if relabel: keep = new_labels != 0 unique_labels = np.unique(new_labels[keep]) new_labels[keep] = np.searchsorted(unique_labels, new_labels[keep]) + 1 relabel_map = _make_relabel_map(self.labels, new_labels, self._data.size) data_new = relabel_map[self.data] # Relabeling is an in-place change, not a data reassignment, # so the auxiliary info, the deblending provenance, and the # per-label flags are kept. The local references are needed # because _set_data rebinds _deblend_label_map and _flags_map to # new empty dicts. deblend_label_map = self._deblend_label_map flags_map = self._flags_map self._set_data(data_new, preserve_info=True) self._deblend_label_map = _remap_deblend_label_map( deblend_label_map, relabel_map) self._flags_map = _remap_flags_map(flags_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. If the segmentation image has no labels, a warning is issued and the image is left unchanged. Parameters ---------- start_label : int, optional The starting label number, which should be a strictly positive integer. The default is 1. Examples -------- >>> 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.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 nonzero 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) old_areas = self.__dict__.get('areas', None) dtype = self.data.dtype # keep the original dtype # The cast keeps a nonnative byte order, which the addition # does not new_labels = (np.arange(self.n_labels, dtype=dtype) + start_label).astype(dtype, copy=False) new_label_map = _make_relabel_map(self.labels, new_labels, self._data.size) data_new = new_label_map[self.data] # Relabeling is an in-place change, not a data reassignment, # so the auxiliary info, the deblending provenance, and the # per-label flags are kept. The local references are needed # because _set_data rebinds _deblend_label_map and _flags_map to # new empty dicts. deblend_label_map = self._deblend_label_map flags_map = self._flags_map # Relabeling is order-preserving, so the areas and slices are # unchanged and carry over under the new label numbers self._set_data(data_new, labels=new_labels, areas=old_areas, slices=old_slices, preserve_info=True) self._deblend_label_map = _remap_deblend_label_map( deblend_label_map, new_label_map) self._flags_map = _remap_flags_map(flags_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 -------- >>> 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.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 -------- >>> 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.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. Raises ------ ValueError If ``label`` is invalid. Examples -------- >>> 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.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 -------- >>> 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.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. It must be positive and smaller than half the array size in any dimension. 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 -------- >>> 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.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 <= 0: msg = 'border_width must be a positive integer' raise ValueError(msg) 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 -------- >>> 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) >>> 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]]) """ mask = np.asarray(mask, dtype=bool) if mask.shape != self.shape: msg = 'mask must have the same shape as the segmentation array' raise ValueError(msg) remove_labels = _get_labels(self.data[mask]) if not partial_overlap: interior_labels = _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. Nonzero 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 rectangular footprint (all elements `True`) 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 # scipy.signal is slow to import, so it is imported only when # needed. from scipy.signal import fftconvolve return fftconvolve(mask, footprint, 'same') > 0.5 @cached_property 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 value is a list of GeoJSON-like dicts. A label with noncontiguous segments has multiple dicts in its list (e.g., from slicing the segmentation image where a segment label is split into noncontiguous segments). A segment with holes has a single dict whose coordinates contain the outer ring followed by the inner rings (holes). 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 @cached_property def polygons(self): """ A list of `Shapely `_ polygons representing each source segment. A `shapely.Polygon` is returned for a contiguous segment, with any holes as interior rings, and a `shapely.MultiPolygon` for a label with noncontiguous pieces. The list matches the order of the ``labels`` attribute. A `ValueError` is raised if the label values cannot be represented as 32-bit integers. """ 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._get_slice(label)) 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. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. It is subtracted from the scaled polygon vertices. 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 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. This method requires the optional rasterio and shapely packages. 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 if rasterio or shapely is 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` or `None` A list of matplotlib patches for the source segments. An element is `None` if the geometry is empty or if rasterio or shapely is not available. 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._get_slice(label)) if poly is None: patches.append(None) continue 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. By default, the patches will have a white edge color and no face color. 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, array_like (1D, int), optional The label number(s) 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 the matplotlib patches that were added to the axes. 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] plotted_patches = [] for patch in patches: patch = copy(patch) ax.add_patch(patch) plotted_patches.append(patch) return plotted_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 noncontiguous 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 noncontiguous 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 noncontiguous 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` or list A `~regions.Regions` object if ``group`` is `False`, or a list of `~regions.PolygonPixelRegion` and `~regions.Regions` objects if ``group`` is `True`. 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 noncontiguous segments for a single label. That can happen as a result of slicing the segmentation image where a segment label is split into noncontiguous 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. This method requires the optional regions package. 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`, `~regions.Regions`, \ or `None` A `~regions.PolygonPixelRegion` object, or a `~regions.Regions` object if the segment is a MultiPolygon (e.g., noncontiguous). `None` is returned if no polygon was found or if rasterio or shapely is 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_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. This method requires the optional regions package. 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`, \ `~regions.Regions`, or `None` A list of `~regions.PolygonPixelRegion` objects, or `~regions.Regions` objects for labels with MultiPolygon segments (e.g., noncontiguous). An element is `None` if no polygon was found or if rasterio or shapely is not available. 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._get_slice(label)) if poly is None: regions.append(None) continue regions.append(_shapely_polygon_to_region( poly, label=int(label), visual_kwargs=visual_kwargs)) return regions def get_label_mapping(self, other, *, labels=None): """ Find the mapping of labels between this segmentation image and another one defined on the same pixel grid. For each label in this segmentation image, find the labels in ``other`` that cover any of the same pixels. The typical use case is comparing segmentation images of the same sources before and after deblending, where the result maps each original (parent) label to the deblended (child) labels that replaced it. Parameters ---------- other : `SegmentationImage` The segmentation image to which the labels are mapped. It must have the same shape as this segmentation image. labels : int or 1D array_like (int), optional The label number(s) to map. If `None` (default), all labels are mapped. Returns ------- mapping : dict A dictionary mapping each label number in this segmentation image to a sorted 1D array of the label numbers in ``other`` that share pixels with it. Notes ----- The two segmentation images are usually assumed to have identical nonzero footprints. That assumption is not checked. If ``other`` contains background (zero) pixels within a labeled region of this segmentation image, then 0 will be included in the mapped labels for that label. For segmentation images deblended in the current session, the :attr:`parent_to_deblended_labels` and :attr:`deblended_label_to_parent` attributes already provide the label mapping. This method is the general tool for two segmentation images without that provenance, e.g., images read from files. The inverse mapping can be computed with ``other.get_label_mapping(self)``. Examples -------- >>> import numpy as np >>> from photutils.segmentation import SegmentationImage >>> segm1 = SegmentationImage(np.array([[1, 1, 0, 2], ... [0, 1, 0, 2]])) >>> segm2 = SegmentationImage(np.array([[1, 3, 0, 2], ... [0, 3, 0, 2]])) >>> segm1.get_label_mapping(segm2) {1: array([1, 3]), 2: array([2])} >>> segm1.get_label_mapping(segm2, labels=1) {1: array([1, 3])} """ if not isinstance(other, SegmentationImage): msg = ('The other segmentation image must be a ' 'SegmentationImage instance') raise TypeError(msg) if other.shape != self.shape: msg = ('The other segmentation image must have the same ' 'shape as this one') raise ValueError(msg) mask = self._data > 0 parents = self._data[mask] children = other._data[mask] if parents.size == 0: mapping = {} else: # Pack each (parent, child) label pair into a single # int64 key so that one sorting pass finds all unique # pairs. The label values are packed, unless the largest # key would overflow. In that case the positions of the # labels in the sorted labels are packed instead, which # costs a search of the labels for every labeled pixel. parent_values = None mult = int(other.max_label) + 1 if (int(self.max_label) + 1) * mult > 2**63: parent_values = self.labels child_values = np.concatenate( (np.zeros(1, dtype=other.labels.dtype), other.labels)) parents = np.searchsorted(parent_values, parents) children = np.searchsorted(child_values, children) mult = len(child_values) mult = np.int64(mult) pairs = np.unique(parents.astype(np.int64) * mult + children.astype(np.int64)) parents = pairs // mult children = pairs % mult if parent_values is not None: parents = parent_values[parents] children = child_values[children] children = children.astype(other._data.dtype) unique_parents, starts = np.unique(parents, return_index=True) mapping = dict(zip(unique_parents.tolist(), np.split(children, starts[1:]), strict=True)) if labels is not None: self.check_labels(labels) mapping = {int(label): mapping[int(label)] for label in np.atleast_1d(labels)} return mapping @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 ``ax`` is input. dpi : float or `None`, optional The figure dots per inch when creating a new Axes. This keyword is ignored if ``ax`` 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 a `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 unique values in the segmentation array, including the background value if present, to consecutive integers starting from 0 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". A segmentation image with no labels is displayed as :meth:`imshow` displays it, without a colorbar. 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 ``ax`` is input. dpi : float or `None`, optional The figure dots per inch when creating a new Axes. This keyword is ignored if ``ax`` 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 and a warning is issued. cbar_labelsize : `None` or float, optional The font size of the colorbar tick labels. Returns ------- result : `matplotlib.image.AxesImage` An image attached to a `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_info = 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) if self.n_labels == 0: return self.imshow(ax=ax, cmap=cmap, alpha=alpha), None 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 # The unique data values are the colorbar tick labels. 0 is # included only if background pixels are present. cbar_labels = data if self.n_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 : int The area of the segment in pixel**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 @cached_property 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 @cached_property 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-24be529/photutils/segmentation/deblend.py000066400000000000000000001117671526452531300243240ustar00rootroot00000000000000# 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 ThreadPoolExecutor from dataclasses import dataclass from itertools import pairwise import numpy as np from astropy.units import Quantity from photutils.segmentation._deblend_markers import (deblend_markers_chunk, deblend_source_stats) from photutils.segmentation._deblend_watershed import (apply_relabel_map, deblend_contrast_chunk, find_present_labels, write_deblended_labels) from photutils.segmentation.core import (SegmentationImage, _get_labels, _make_relabel_map, _max_dense_label, _remap_deblend_label_map) from photutils.segmentation.flags import SEGMENTATION_FLAGS from photutils.segmentation.utils import _make_binary_structure from photutils.utils._deprecation import deprecated_renamed_argument from photutils.utils.exceptions import DeblendWarning __all__ = ['deblend_sources'] # The number of markers above which a source is deblended again with # linearly spaced threshold levels, which have fewer levels at low # thresholds. The value is arbitrary but works well in practice _MAX_MARKERS = 200 def _validate_deblend_kwargs(*, n_levels, contrast, contrast_method, mode, connectivity, n_threads): """ Validate the deblending keywords shared by `deblend_sources` and `~photutils.segmentation.SourceFinder`. Parameters ---------- n_levels, contrast, contrast_method, mode, connectivity, n_threads The keyword values to validate. See `deblend_sources`. Raises ------ ValueError If any of the values is invalid. """ if (n_levels < 1) or (int(n_levels) != n_levels): msg = f'n_levels must be a positive integer, got {n_levels!r}' raise ValueError(msg) if contrast < 0 or contrast > 1: msg = 'contrast must be >= 0 and <= 1' raise ValueError(msg) if contrast_method not in (None, 'basin', 'saddle'): msg = "contrast_method must be None, 'basin', or 'saddle'" raise ValueError(msg) if mode not in ('exponential', 'linear', 'sinh'): msg = "mode must be 'exponential', 'linear', or 'sinh'" raise ValueError(msg) if connectivity not in (4, 8): msg = f'Invalid connectivity={connectivity}. Options are 4 or 8' raise ValueError(msg) if (isinstance(n_threads, bool) or not isinstance(n_threads, (int, np.integer)) or n_threads < 1): msg = f'n_threads must be a positive integer, got {n_threads!r}' raise ValueError(msg) @dataclass class _DeblendParams: n_pixels: int footprint: np.ndarray n_levels: int contrast: float mode: str contrast_method: str = 'basin' @dataclass class _ChunkResult: """ The deblending results of a chunk of sources. The deblended labels of every source are stored in the packed cutout layout of the compiled kernels. The region of source ``i`` is ``packed[offsets[i]:offsets[i + 1]]``, its bounding-box cutout in raster order. It holds consecutive labels from 1 if the source deblended (``n_labels[i] >= 2``), and zeros otherwise. """ n_labels: np.ndarray packed: np.ndarray offsets: np.ndarray y0: np.ndarray y1: np.ndarray x0: np.ndarray x1: np.ndarray nonposmin: np.ndarray n_markers_fallback: np.ndarray @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', None, '3.0', until='4.0') @deprecated_renamed_argument('n_processes', None, '3.1', until='4.0') @deprecated_renamed_argument('progress_bar', None, '3.1', until='4.0') def deblend_sources(data, segmentation_image, n_pixels, *, labels=None, n_levels=32, contrast=0.001, contrast_method=None, mode='exponential', connectivity=8, relabel=True, n_threads=1, nproc=1, # noqa: ARG001 n_processes=1, # noqa: ARG001 progress_bar=True): # noqa: ARG001 """ Deblend overlapping sources labeled in a segmentation image. Sources are deblended using a combination of multithresholding and `watershed segmentation `_. In order to deblend sources, they must be separated enough that there is a saddle point between them. Parameters ---------- data : 2D `~numpy.ndarray` or `~astropy.units.Quantity` 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`. NaN pixels within a source segment are excluded from the multithreshold levels and the source flux, and are assigned to a neighboring deblended source after all finite pixels have been assigned. segmentation_image : `~photutils.segmentation.SegmentationImage` The segmentation image to deblend. n_pixels : int The minimum number of connected pixels, each greater than a given multithresholding level, that a local peak must have at that level to be considered a separate object. ``n_pixels`` must be a positive integer. Sources with fewer than ``2 * n_pixels`` pixels are not deblended. 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 multithresholding levels to use for deblending. Each source will be rethresholded at ``n_levels`` levels spaced between its minimum and maximum values (noninclusive). 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 multithresholds) 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. The ``contrast_method`` keyword selects the flux to which the fraction refers. contrast_method : {`None`, 'basin', 'saddle'}, optional The flux used by the contrast criterion. For ``'basin'``, the fraction is the total flux in the source's watershed basin, which includes the share of the surrounding envelope territory assigned to the source. Basins below the contrast are removed iteratively (faintest first, in batches when provably equivalent) and their territory is reflooded. For ``'saddle'``, the fraction is the flux the source holds above the saddle level where it separates from its neighbors, evaluated once during marker construction with no iteration. This measures the significance of the peak itself, independently of how much envelope territory it would inherit. Because ``'saddle'`` needs only a single watershed pass, it is never slower than ``'basin'`` and is substantially faster when many below-contrast basins would otherwise be removed iteratively. Note that the two methods measure different fluxes, so the same ``contrast`` value selects sources differently. The saddle flux excludes all regions below the saddle and is therefore smaller than the basin flux, making ``'saddle'`` stricter for faint peaks on bright envelopes. If `None` (default), the ``'basin'`` method is currently used. The default may change to ``'saddle'`` in version 4.0. mode : {'exponential', 'linear', 'sinh'}, optional The mode used in defining the spacing between the multithresholding levels (see the ``n_levels`` keyword) during deblending. The ``'exponential'`` and ``'sinh'`` modes have more threshold levels near the source minimum and fewer 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 and larger ratios are more exponential), while the ``'sinh'`` levels are not. Unlike the ``'exponential'`` mode, the ``'sinh'`` and ``'linear'`` modes are well defined for sources with nonpositive minimum data values. For such sources, the ``'exponential'`` mode will be changed to ``'sinh'``. In addition, with the ``'basin'`` contrast method, a source that has more than 200 potential deblended sources (markers) in the ``'exponential'`` or ``'sinh'`` mode is deblended again in the ``'linear'`` mode, because that many markers make the deblending very slow. 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. A `ValueError` is raised if a source cannot be deblended because the connectivities differ. relabel : bool, optional If `True` (default), then the segmentation image will be relabeled such that the labels are in consecutive order starting from 1. If `False`, the labels of the sources that are not deblended are unchanged, and each deblended source is replaced by new labels that are larger than the maximum label of the input segmentation image. n_threads : int, optional The number of threads to use to deblend the sources. The default is 1 (no multithreading). When ``n_threads`` > 1, the sources are divided into chunks and processed concurrently. The per-source results are independent and are assembled in the input-label order, so they are identical to the single-threaded computation. Each chunk is deblended by a few compiled calls that release the Python global interpreter lock (GIL), so multithreading speeds up the deblending of fields of many small sources as well as of large sources. nproc : int, optional This keyword is deprecated and has no effect. It was the name of the ``n_processes`` keyword before version 3.0. .. deprecated:: 3.0 The ``nproc`` keyword is deprecated and will be removed in version 4.0. n_processes : int, optional This keyword is deprecated and has no effect. Multiprocessing no longer provides any benefit. The deblending computation is now dominated by compiled code, and the process startup and data-pickling overheads of multiprocessing made it slower than the serial implementation. Use the ``n_threads`` keyword instead. .. deprecated:: 3.1 The ``n_processes`` keyword is deprecated and will be removed in version 4.0. progress_bar : bool, optional This keyword is deprecated and has no effect. Deblending no longer displays a progress bar. .. deprecated:: 3.1 The ``progress_bar`` keyword is deprecated and will be removed in version 4.0. 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. The ``info`` attribute of the returned segmentation image is a dictionary that stores the input labels for which the deblending mode was changed to a fallback mode as arrays under ``'non_positive_min_labels'`` (nonpositive minimum data values, changed to "sinh") and ``'too_many_markers_labels'`` (too many potential deblended sources, changed to "linear") keys. The dictionary is empty if no mode fallbacks occurred. The ``flags`` attribute of the returned segmentation image records per-source deblending provenance. See `~photutils.segmentation.decode_segmentation_flags`. Warns ----- DeblendWarning If the deblending mode for one or more sources was changed from ``mode`` to "sinh" due to nonpositive minimum data values or to "linear" due to too many potential deblended sources. 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 segmentation_image.n_labels == 0: msg = 'segmentation_image must have at least one nonzero label' raise ValueError(msg) 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) _validate_deblend_kwargs(n_levels=n_levels, contrast=contrast, contrast_method=contrast_method, mode=mode, connectivity=connectivity, n_threads=n_threads) if contrast_method is None: # The default resolution is planned to change to 'saddle' in # version 4.0 contrast_method = 'basin' if labels is None: labels = segmentation_image.labels else: labels = np.atleast_1d(labels) segmentation_image.check_labels(labels) if contrast == 1: # no deblending segm_img = SegmentationImage._from_data( segmentation_image.data.copy()) if relabel: segm_img.relabel_consecutive() return segm_img # 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, contrast_method) label_indices = segmentation_image.get_indices(labels) all_slices = [segmentation_image.slices[idx] for idx in label_indices] # Contiguous, native-byte-order views of the inputs for the # compiled kernels. Casting the non-float data dtypes to float64 is # exact for the threshold comparisons, matching the NumPy promotion # rules if data.dtype.type in (np.float32, np.float64): driver_dtype = data.dtype.newbyteorder('=') else: driver_dtype = np.float64 driver_data = np.ascontiguousarray(data, dtype=driver_dtype) segm_data = segmentation_image.data if segm_data.dtype.type in (np.int32, np.int64): driver_dtype = segm_data.dtype.newbyteorder('=') else: driver_dtype = np.int64 driver_segm = np.ascontiguousarray(segm_data, dtype=driver_dtype) n_chunks = min(int(n_threads), len(labels)) if n_chunks > 1: # The sources are dealt out to the chunks in decreasing order of # their bounding-box area (the size of the cutouts the kernels # work on), so that the chunks carry similar amounts of work # regardless of the source ordering. The chunks are processed # concurrently and the per-source results are gathered back # into the input order, so they are identical to the # single-threaded computation. bbox_areas = np.array([(slc[0].stop - slc[0].start) * (slc[1].stop - slc[1].start) for slc in all_slices]) order = np.argsort(bbox_areas, kind='stable')[::-1] chunk_indices = [np.sort(order[i::n_chunks]) for i in range(n_chunks)] def _run_chunk(indices): chunk_slices = [all_slices[idx] for idx in indices] return _deblend_sources_chunk(data, segm_data, driver_data, driver_segm, labels[indices], chunk_slices, deblend_params) with ThreadPoolExecutor(max_workers=n_chunks) as executor: chunk_results = list(zip(chunk_indices, executor.map(_run_chunk, chunk_indices), strict=True)) else: chunk_results = [(np.arange(len(labels)), _deblend_sources_chunk(data, segm_data, driver_data, driver_segm, labels, all_slices, deblend_params))] # Gather the per-source counts and fallback flags in the input # order. The deblended labels of each source follow its # predecessors, so the label offsets are a cumulative sum of the # counts, starting after the largest input label n_labels = np.zeros(len(labels), dtype=np.intp) nonposmin = np.zeros(len(labels), dtype=bool) n_markers_fallback = np.zeros(len(labels), dtype=bool) for indices, result in chunk_results: n_labels[indices] = result.n_labels nonposmin[indices] = result.nonposmin n_markers_fallback[indices] = result.n_markers_fallback deblended = n_labels >= 2 counts = np.where(deblended, n_labels, 0) label_offsets = np.zeros(len(labels), dtype=np.int64) np.cumsum(counts[:-1], out=label_offsets[1:]) label_offsets += int(segmentation_image.max_label) segm_out = driver_segm.copy() for indices, result in chunk_results: write_deblended_labels(segm_out, result.packed, result.offsets[:-1], result.y0, result.y1, result.x0, result.x1, result.n_labels, label_offsets[indices]) # The labels are made consecutive in place by compiled code, before # the cast to the dtype of the input segmentation image, unless the # labels are too large for its per-label arrays relabel_map = None max_label = int(segmentation_image.max_label) + int(counts.sum()) use_kernel = max_label <= _max_dense_label(segm_out.size) if relabel and use_kernel: relabel_map = _relabel_consecutive(segm_out, max_label, int(n_threads)) segm_deblended = segm_out.astype(segm_data.dtype, copy=False) # The child labels carry the dtype of the output segmentation # image, as the image itself does deblend_label_map = { int(label): (np.arange(1, count + 1) + offset).astype( segm_deblended.dtype) for label, count, offset in zip(labels[deblended], counts[deblended], label_offsets[deblended], strict=True)} non_positive_min_labels = list(labels[nonposmin]) too_many_markers_labels = list(labels[n_markers_fallback]) if non_positive_min_labels or too_many_markers_labels: msg = ('The deblending mode of one or more source labels from the ' f'input segmentation image was changed from "{mode}" to a ' 'fallback mode. See the "info" attribute of the returned ' 'segmentation image for the affected input labels and the ' '"mode" documentation for the fallback rules.') warnings.warn(msg, DeblendWarning) if relabel and not use_kernel: relabel_map = _create_relabel_map(segm_deblended, start_label=1) if relabel_map is not None: segm_deblended = relabel_map[segm_deblended] elif relabel_map is not None: # This is the map of the compiled relabeling, which is always # an array and never a _SparseRelabelMap relabel_map = relabel_map.astype(segm_deblended.dtype, copy=False) if relabel_map is not None: deblend_label_map = _remap_deblend_label_map(deblend_label_map, relabel_map) segm_img = SegmentationImage._from_data( segm_deblended, deblend_label_map=deblend_label_map) # Store the input labels affected by deblending mode fallbacks in # the info attribute if non_positive_min_labels: segm_img.info['non_positive_min_labels'] = np.array( non_positive_min_labels) if too_many_markers_labels: segm_img.info['too_many_markers_labels'] = np.array( too_many_markers_labels) segm_img._flags_map = _make_flags_map( deblend_label_map, non_positive_min_labels, too_many_markers_labels, relabel_map) return segm_img def _linspace_rows(start, stop, num): """ Evaluate ``np.linspace(start[i], stop[i], num)`` for every row. This replicates the NumPy implementation operation for operation (the evaluation dtype, the step computation, and the zero-step special case), so that every row is bitwise identical to the scalar ``np.linspace`` call of the pure-Python reference implementation. Parameters ---------- start, stop : 1D `~numpy.ndarray` The start and stop values of each row. num : int The number of samples per row. Returns ------- result : 2D `~numpy.ndarray` The samples, with shape ``(len(start), num)``. """ dtype = np.result_type(start, stop) if not np.issubdtype(dtype, np.inexact): dtype = np.float64 div = num - 1 delta = np.subtract(stop, start, dtype=dtype) samples = np.arange(0, num, dtype=dtype) step = delta / div result = samples[None, :] * step[:, None] zero_step = step == 0 if np.any(zero_step): # The np.linspace special case for subnormal steps result[zero_step] = (samples / div)[None, :] * delta[zero_step, None] result += start[:, None] result[:, -1] = stop return result def _compute_thresholds(source_min, source_max, n_levels, mode): """ Compute the multithreshold levels of a set of sources. This is the vectorized form of the per-source threshold computation of the pure-Python reference implementation (``_SingleSourceDeblender.compute_thresholds``). It performs the same NumPy operations in the same dtypes, so the levels are bitwise identical to the reference implementation on every platform. Parameters ---------- source_min, source_max : 1D `~numpy.ndarray` The minimum and maximum data value of each source segment, in the data dtype. Each maximum must be larger than its minimum. n_levels : int The number of levels per source. mode : {'exponential', 'linear', 'sinh'} The level spacing. For the ``'exponential'`` mode, the sources with a nonpositive minimum use the ``'sinh'`` spacing. Returns ------- thresholds : 2D float64 `~numpy.ndarray` The levels of each source, with shape ``(n_sources, n_levels)`` and ascending along the second axis. The source minimum and maximum are excluded. nonposmin : 1D bool `~numpy.ndarray` Whether each source fell back to the ``'sinh'`` spacing because of a nonpositive minimum. """ source_min = np.asarray(source_min) source_max = np.asarray(source_max) nonposmin = np.zeros(source_min.shape, dtype=bool) if mode == 'exponential': nonposmin = source_min <= 0 thresholds = _linspace_rows(source_min, source_max, n_levels + 2) if mode != 'linear': delta = source_max - source_min normalized = (thresholds - source_min[:, None]) / delta[:, None] # The exponential rows keep the data dtype of the reference # implementation (widened exactly below) while the sinh rows # are float64, so the rows are combined in float64 thresholds = thresholds.astype(np.float64) if mode == 'exponential': use_sinh = nonposmin keep = ~nonposmin ratio = source_max[keep] / source_min[keep] thresholds[keep] = (source_min[keep, None] * ratio[:, None] ** normalized[keep]) else: use_sinh = np.ones(source_min.shape, dtype=bool) if np.any(use_sinh): # The exponential spacing is undefined for nonpositive # minima, so those sources use the sinh spacing, which # keeps the levels concentrated near the source minimum and # is defined for any data values a = 0.25 levels = np.sinh(normalized[use_sinh] / a) / np.sinh(1.0 / a) levels *= delta[use_sinh, None] levels += source_min[use_sinh, None] thresholds[use_sinh] = levels # Do not include the source minimum and maximum return (np.ascontiguousarray(thresholds[:, 1:-1], dtype=np.float64), nonposmin) def _deblend_sources_chunk(data, segm_data, # noqa: ARG001 driver_data, driver_segm, labels, slices, deblend_params): """ Deblend a chunk of labeled sources. The signature is shared with the pure-Python chunk function of the cross-implementation tests, which needs ``segm_data`` even though the compiled kernels read only ``driver_segm``. The data extrema of every source in the chunk are computed by a compiled kernel, the multithreshold levels and the mode fallbacks are computed for all sources at once in NumPy, the level quantization and the marker construction run in the compiled chunk driver (which releases the GIL and reuses one workspace across the chunk), and the watershed and contrast steps then run for the sources that split into two or more markers, in one compiled call over the chunk. Parameters ---------- data : 2D `~numpy.ndarray` The data array. segm_data : 2D int `~numpy.ndarray` The segmentation array. driver_data, driver_segm : 2D `~numpy.ndarray` Contiguous views (or exact casts) of ``data`` and ``segm_data`` with dtypes supported by the compiled kernels. labels : 1D `~numpy.ndarray` The labels of the sources in the chunk. slices : list of tuple of slice The bounding-box slices of the sources in the chunk. deblend_params : `_DeblendParams` The parameters for deblending the sources. Returns ------- result : `_ChunkResult` The per-source deblended labels and mode-fallback flags. """ labels = np.asarray(labels, dtype=np.int64) y0 = np.array([slc[0].start for slc in slices], dtype=np.int64) y1 = np.array([slc[0].stop for slc in slices], dtype=np.int64) x0 = np.array([slc[1].start for slc in slices], dtype=np.int64) x1 = np.array([slc[1].stop for slc in slices], dtype=np.int64) connectivity = 8 if deblend_params.footprint[0, 0] else 4 n_levels = int(deblend_params.n_levels) mode = deblend_params.mode chunk_kwargs = {'n_pixels': int(deblend_params.n_pixels), 'connectivity': connectivity} source_min, source_max, source_sum = deblend_source_stats( driver_data, driver_segm, labels, y0, y1, x0, x1) # Constant (or all-NaN) sources do not deblend. The multithreshold # levels of the other sources are computed in the data dtype, as # the pure-Python reference implementation does active = np.flatnonzero(source_min < source_max) nonposmin = np.zeros(len(labels), dtype=bool) n_markers_fallback = np.zeros(len(labels), dtype=bool) n_markers = np.zeros(len(labels), dtype=np.intp) # One packed buffer holds the bounding-box cutout of every source # in the chunk, back to back. The kernels write the markers and # then the deblended labels into it, so no per-source arrays are # created in Python sizes = (y1 - y0) * (x1 - x0) offsets = np.zeros(len(labels) + 1, dtype=np.intp) np.cumsum(sizes, out=offsets[1:]) packed = np.zeros(offsets[-1], dtype=np.int32) starts = offsets[:-1] use_saddle = deblend_params.contrast_method == 'saddle' if active.size > 0: values_dtype = data.dtype.newbyteorder('=') smin = source_min[active].astype(values_dtype) smax = source_max[active].astype(values_dtype) thresholds, fallback = _compute_thresholds(smin, smax, n_levels, mode) nonposmin[active] = fallback saddle_limits = None if use_saddle: saddle_limits = deblend_params.contrast * source_sum[active] # Sources with too many markers are only retried with linearly # spaced levels (below), so only then may the kernel skip # building their markers. With the saddle criterion the markers # are already contrast-selected and there is no iterative # watershed, so no fallback is needed. can_retry = mode != 'linear' and not use_saddle max_markers = _MAX_MARKERS if can_retry else -1 n_markers[active] = deblend_markers_chunk( driver_data, driver_segm, labels[active], y0[active], y1[active], x0[active], x1[active], thresholds, packed, starts[active], max_markers=max_markers, saddle_limits=saddle_limits, **chunk_kwargs) # Too many markers make the watershed step very slow, so such # sources are deblended again with linearly spaced levels if can_retry: retry = np.flatnonzero(n_markers[active] > _MAX_MARKERS) if retry.size > 0: thresholds, _ = _compute_thresholds( smin[retry], smax[retry], n_levels, 'linear') retry = active[retry] n_markers[retry] = deblend_markers_chunk( driver_data, driver_segm, labels[retry], y0[retry], y1[retry], x0[retry], x1[retry], thresholds, packed, starts[retry], max_markers=-1, **chunk_kwargs) n_markers_fallback[retry] = True n_labels = deblend_contrast_chunk( driver_data, driver_segm, labels, y0, y1, x0, x1, packed, starts, n_markers, connectivity=connectivity, contrast=float(deblend_params.contrast), source_sum=source_sum, source_min=source_min, apply_contrast=not use_saddle) return _ChunkResult(n_labels=n_labels, packed=packed, offsets=offsets, y0=y0, y1=y1, x0=x0, x1=x1, nonposmin=nonposmin, n_markers_fallback=n_markers_fallback) def _make_flags_map(deblend_label_map, non_positive_min_labels, too_many_markers_labels, relabel_map): """ Build the per-label flags mapping for a deblended segmentation image. Deblended children get the "deblended" flag. Mode-fallback flags are set on the children of affected parents, or on the parent's own (possibly relabeled) output label if it did not split. Parameters ---------- deblend_label_map : dict Mapping of input parent labels to arrays of output child labels, in the final (post-relabel) label frame. non_positive_min_labels, too_many_markers_labels : list of int Input parent labels affected by each mode fallback. relabel_map : `~numpy.ndarray`, ``_SparseRelabelMap``, or `None` The relabeling map applied to the deblended image, or `None` if no relabeling was applied. Returns ------- flags_map : dict Mapping of output labels to bitwise flag values. """ flags_map = {} for children in deblend_label_map.values(): for child in children: child = int(child) flags_map[child] = (flags_map.get(child, 0) | SEGMENTATION_FLAGS.DEBLENDED) fallbacks = [ (non_positive_min_labels, SEGMENTATION_FLAGS.DEBLEND_NON_POSITIVE_MIN), (too_many_markers_labels, SEGMENTATION_FLAGS.DEBLEND_TOO_MANY_MARKERS), ] for input_labels, bit in fallbacks: for label in input_labels: label = int(label) if label in deblend_label_map: targets = [ int(child) for child in deblend_label_map[label] ] else: # The source did not split. Translate its input label to # the output label frame. if relabel_map is None: out_label = label else: out_label = int(relabel_map[label]) targets = [out_label] for target in targets: flags_map[target] = flags_map.get(target, 0) | bit return flags_map def _relabel_consecutive(segm, max_label, n_threads): """ Relabel a segmentation array in place so that its labels are consecutive integers starting from 1, keeping their order. The labels that are present are found, and the array is then rewritten, by compiled code that releases the GIL. When ``n_threads`` > 1, the image is divided into bands of rows that are processed concurrently. The result is identical to the single-threaded computation. Parameters ---------- segm : 2D int32 or int64 `~numpy.ndarray` The C-contiguous segmentation array to relabel in place. max_label : int An upper limit of the label values in ``segm``. n_threads : int The number of threads. Returns ------- relabel_map : 1D int64 `~numpy.ndarray` or None The array mapping the original labels to the new labels. If the labels are already consecutive starting from 1, then ``segm`` is left unchanged and `None` is returned. """ n_bands = max(min(n_threads, segm.shape[0]), 1) edges = np.arange(n_bands + 1) * segm.shape[0] // n_bands bands = [segm[i0:i1] for i0, i1 in pairwise(edges)] def find_present(band): present = np.zeros(max_label + 1, dtype=np.uint8) find_present_labels(band, present) return present def get_relabel_map(present): present[0] = 0 # the background is not a label labels = np.flatnonzero(present) if len(labels) == 0 or labels[-1] == len(labels): return None # already consecutive relabel_map = np.zeros(max_label + 1, dtype=np.int64) relabel_map[labels] = np.arange(1, len(labels) + 1) return relabel_map if n_bands == 1: relabel_map = get_relabel_map(find_present(segm)) if relabel_map is not None: apply_relabel_map(segm, relabel_map) return relabel_map with ThreadPoolExecutor(max_workers=n_bands) as executor: present = np.bitwise_or.reduce( list(executor.map(find_present, bands))) relabel_map = get_relabel_map(present) if relabel_map is not None: list(executor.map(lambda band: apply_relabel_map( band, relabel_map), bands)) return relabel_map 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`, `_SparseRelabelMap`, or None The map from the original labels to the new labels (see ``_make_relabel_map``). 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 the map from the old labels to the new labels new_labels = (np.arange(len(labels)) + start_label).astype(array.dtype) return _make_relabel_map(labels, new_labels, array.size) astropy-photutils-24be529/photutils/segmentation/detect.py000066400000000000000000000441451526452531300241720ustar00rootroot00000000000000# 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 astropy.utils.exceptions import AstropyUserWarning 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. If ``data`` is a `~astropy.units.Quantity` array, then ``background`` must have the same units as ``data``. 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. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` must have the same units 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 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. The default is a `~astropy.stats.SigmaClip` with ``sigma=3.0`` and ``maxiters=10``. 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. If ``background`` or ``error`` is estimated and the unmasked ``data`` contain nonfinite values (NaN or inf), then an `~astropy.utils.exceptions.AstropyUserWarning` is emitted and the nonfinite values are excluded from the statistics, with or without sigma clipping. """ 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 sigma_clip is not None and not isinstance(sigma_clip, SigmaClip): msg = 'sigma_clip must be a SigmaClip object or None' raise TypeError(msg) if background is None or error is None: if mask is not None: data = np.ma.MaskedArray(data, mask) if sigma_clip is None: # Masked and nonfinite values are excluded from the # statistics clipped_data = np.ma.getdata(data).astype(float) masked = np.ma.getmaskarray(data) nonfinite = ~np.isfinite(clipped_data) if np.any(nonfinite & ~masked): msg = ('Input data contains nonfinite values (NaN or ' 'inf), which were excluded from the background ' 'statistics.') warnings.warn(msg, AstropyUserWarning) clipped_data[nonfinite | masked] = np.nan else: clipped_data = sigma_clip(data, masked=False, return_bounds=False, copy=True) if background is None: background = nanmean(clipped_data) if np.ndim(background) != 0 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 np.ndim(error) != 0 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): """ Detect sources above a specified threshold value in an image. Detected sources must have at least ``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. 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` or `None` 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. If `None`, then no pixels are masked. 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. """ # NaN values compare as False, so NaN pixels are never included # in any source 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, n_labels = ndi_label(segment_img, structure=footprint) labels = np.arange(n_labels, 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 = [] segm_areas = [] for label, slc in zip(labels, slices, strict=True): cutout = segment_img[slc] segment_mask = (cutout == label) # The pixel count is the segment area, so it is kept to seed # the SegmentationImage areas instead of being recomputed area = np.count_nonzero(segment_mask) if area < n_pixels: cutout[segment_mask] = 0 continue segm_labels.append(label) segm_slices.append(slc) segm_areas.append(area) if not segm_labels: return None # 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 n_labels = len(segm_labels) if len(labels) != n_labels: label_map = np.zeros(np.max(labels) + 1, dtype=segment_img.dtype) labels = np.arange(n_labels, dtype=segment_img.dtype) + 1 label_map[segm_labels] = labels segment_img = label_map[segment_img] return SegmentationImage._from_data(segment_img, labels=labels, areas=np.array(segm_areas), slices=segm_slices) def _warn_negative_threshold(threshold): """ Warn if the detection threshold is negative. Sources are defined by pixels that are strictly greater than the threshold, so a negative threshold can include pixels with zero or negative values in the detected sources. Such pixels contribute to the source areas, but `~photutils.segmentation.SourceCatalog` sets negative data values to zero when computing the source moments, so they do not contribute to the centroids and shapes. A source whose minimum data value is not positive also switches `~photutils.segmentation.deblend_sources` from its exponential mode to its sinh fallback mode. Parameters ---------- threshold : float, 2D `~numpy.ndarray`, or `~astropy.units.Quantity` The detection threshold. NaN values are ignored. """ (values,), _ = process_quantities((threshold,), ('threshold',)) values = np.asanyarray(values) if values.ndim == 0: if values < 0: msg = (f'threshold is negative ({values}). Pixels with zero ' 'or negative values can be included in the detected ' 'sources. SourceCatalog sets negative data values to ' 'zero when computing the source moments.') warnings.warn(msg, AstropyUserWarning) return n_bad = np.count_nonzero(values < 0) if n_bad > 0: msg = (f'threshold has {n_bad} negative value(s). Pixels with ' 'zero or negative values can be included in the detected ' 'sources. SourceCatalog sets negative data values to zero ' 'when computing the source moments.') warnings.warn(msg, AstropyUserWarning) @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 at least ``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. Pixels with NaN values are never included in a source. 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``. A warning is emitted if the threshold is negative (or, for an array, has any negative values), because pixels with zero or negative values can then be included in the detected sources. 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 ------ ValueError If ``n_pixels`` is not a positive integer, if ``connectivity`` is not 4 or 8, if ``data`` and ``threshold`` do not have the same units, if ``mask`` does not have the same shape as ``data``, or if ``mask`` is `True` for every pixel. Warns ----- NoDetectionsWarning If no sources are found. AstropyUserWarning If ``threshold`` is negative or, for an array, has any negative values. 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(nrows=2, 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')) _warn_negative_threshold(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) if segm is None: msg = ('No sources were found. Try lowering the threshold or ' 'n_pixels parameters.') warnings.warn(msg, NoDetectionsWarning) return segm astropy-photutils-24be529/photutils/segmentation/finder.py000066400000000000000000000315711526452531300241700ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for detecting sources in an image. """ import numpy as np from photutils.segmentation.deblend import (_validate_deblend_kwargs, 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 multithresholding and `watershed segmentation `_. In order to deblend sources, they must be separated enough that there is a saddle point 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 multithresholding levels to use for deblending. Each source will be rethresholded at ``n_levels`` levels spaced between its minimum and maximum values (noninclusive). The ``mode`` keyword determines how the levels are spaced. 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 multithresholds) 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``. contrast_method : {`None`, 'basin', 'saddle'}, optional The flux used by the contrast criterion. For ``'basin'``, the fraction is the total flux in the source's watershed basin. For ``'saddle'``, the fraction is the flux the source holds above the saddle level where it separates from its neighbors. If `None` (default), the ``'basin'`` method is currently used. The default may change to ``'saddle'`` in version 4.0. See :func:`~photutils.segmentation.deblend_sources` for details. This keyword is ignored unless ``deblend=True``. mode : {'exponential', 'linear', 'sinh'}, optional The mode used in defining the spacing between the multithresholding levels (see the ``n_levels`` keyword) during deblending. The ``'exponential'`` and ``'sinh'`` modes have more threshold levels near the source minimum and fewer 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 and larger ratios are more exponential), while the ``'sinh'`` levels are not. Unlike the ``'exponential'`` mode, the ``'sinh'`` and ``'linear'`` modes are well defined for sources with nonpositive minimum data values. For such sources, the ``'exponential'`` mode will be changed to ``'sinh'``. 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_threads : int, optional The number of threads to use to deblend the sources. The default is 1 (no multithreading). When ``n_threads`` > 1, the sources are divided into chunks and processed concurrently, producing results identical to the single-threaded computation. This keyword is ignored unless ``deblend=True``. nproc : int, optional This keyword is deprecated and has no effect. It was the name of the ``n_processes`` keyword before version 3.0. .. deprecated:: 3.0 The ``nproc`` keyword is deprecated and will be removed in version 4.0. n_processes : int, optional This keyword is deprecated and has no effect. Multiprocessing no longer provides any benefit for source deblending. Use the ``n_threads`` keyword instead. .. deprecated:: 3.1 The ``n_processes`` keyword is deprecated and will be removed in version 4.0. progress_bar : bool, optional This keyword is deprecated and has no effect. Deblending no longer displays a progress bar. .. deprecated:: 3.1 The ``progress_bar`` keyword is deprecated and will be removed in version 4.0. 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) segment_map = finder(convolved_data, threshold) # Plot the image and the segmentation image fig, (ax1, ax2) = plt.subplots(nrows=2, 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', None, '3.0', until='4.0') @deprecated_renamed_argument('n_processes', None, '3.1', until='4.0') @deprecated_renamed_argument('progress_bar', None, '3.1', until='4.0') def __init__(self, n_pixels, *, connectivity=8, deblend=True, n_levels=32, contrast=0.001, contrast_method=None, mode='exponential', relabel=True, n_threads=1, nproc=1, # noqa: ARG002 n_processes=1, progress_bar=True): self.n_pixels = as_pair('n_pixels', n_pixels, check_odd=False) for name, value in (('deblend', deblend), ('relabel', relabel)): if not isinstance(value, (bool, np.bool_)): msg = f'{name} must be a boolean, got {value!r}' raise TypeError(msg) _validate_deblend_kwargs(n_levels=n_levels, contrast=contrast, contrast_method=contrast_method, mode=mode, connectivity=connectivity, n_threads=n_threads) self.deblend = deblend self.connectivity = connectivity self.n_levels = n_levels self.contrast = contrast self.contrast_method = contrast_method self.mode = mode self.relabel = relabel self.n_threads = n_threads self.n_processes = n_processes self.progress_bar = progress_bar def __repr__(self): params = ('n_pixels', 'deblend', 'connectivity', 'n_levels', 'contrast', 'contrast_method', 'mode', 'relabel', 'n_threads', '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``. A warning is emitted if the threshold is negative (or, for an array, has any negative values), because pixels with zero or negative values can then be included in the detected sources. 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. Warns ----- NoDetectionsWarning If no sources are found. AstropyUserWarning If ``threshold`` is negative or, for an array, has any negative values. DeblendWarning If ``deblend=True`` and the deblending mode for one or more sources was changed from ``mode`` to "sinh" due to nonpositive minimum data values or to "linear" due to too many potential deblended sources. """ segment_img = detect_sources(data, threshold, self.n_pixels[0], mask=mask, connectivity=self.connectivity) if segment_img is None: return None if self.deblend: segment_img = deblend_sources(data, segment_img, self.n_pixels[1], n_levels=self.n_levels, contrast=self.contrast, contrast_method=( self.contrast_method), mode=self.mode, connectivity=self.connectivity, relabel=self.relabel, n_threads=self.n_threads) return segment_img astropy-photutils-24be529/photutils/segmentation/flags.py000066400000000000000000000415261526452531300240160ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for working with segmentation quality flags, including centralized flag definitions and decoding utilities. """ from typing import ClassVar from photutils.utils._flags import (FlagDefinition, FlagRegistry, decode_flags, update_flag_docstring) __all__ = ['SEGMENTATION_FLAGS', 'decode_segmentation_flags'] class _SegmentationFlags(FlagRegistry): """ Centralized definition of segmentation quality flags. This class provides a single source of truth for all segmentation flag definitions, including bit values, names, and descriptions. The same flag definitions are used by `~photutils.segmentation.SegmentationImage`, `~photutils.segmentation.deblend_sources`, and `~photutils.segmentation.SourceCatalog`, so a given bit always has the same meaning. Flags that describe the same condition as an aperture flag (see `~photutils.aperture.decode_aperture_flags`), with the source segment as the measurement region, use the same name as the aperture flag. The bit values are independent between packages. Always decode a flag column with the decoder of the package that produced it. Flags describing the Kron aperture use a ``kron_`` name prefix. Examples -------- >>> from photutils.segmentation.flags import _SegmentationFlags >>> flags = _SegmentationFlags() >>> flags.DEBLENDED 32 >>> flags.get_name(32) 'deblended' """ # Define all segmentation flags with their properties FLAG_DEFINITIONS: ClassVar = [ FlagDefinition( bit_value=1, name='masked_pixels', description='masked pixels within the source segment', detailed_description=('One or more input-masked pixels ' '(``mask`` keyword) are within the ' 'source segment.'), ), FlagDefinition( bit_value=2, name='non_finite_data', description=('nonfinite data values within the source ' 'segment'), detailed_description=('One or more data values within ' 'the source segment are nonfinite ' '(NaN or inf).'), ), FlagDefinition( bit_value=4, name='non_finite_error', description=('nonfinite error values within the source ' 'segment'), detailed_description=('One or more error values within ' 'the source segment are nonfinite ' '(NaN or inf).'), ), FlagDefinition( bit_value=8, name='all_masked', description='no valid pixels within the source segment', detailed_description=('Every pixel within the source ' 'segment is masked or nonfinite, ' 'so the measured source properties ' 'are undefined.'), ), FlagDefinition( bit_value=16, name='edge_touch', description='source touches an image boundary', detailed_description=('The source segment touches one or ' 'more image boundaries, so the ' 'source may be truncated and its ' 'measured properties may be ' 'unreliable.'), ), FlagDefinition( bit_value=32, name='deblended', description='source was produced by deblending', detailed_description=('The source was produced by ' 'deblending a parent source with ' '`~photutils.segmentation' '.deblend_sources`.'), ), FlagDefinition( bit_value=64, name='deblend_non_positive_min', description=('deblending mode changed to sinh: ' 'nonpositive minimum'), detailed_description=('The deblending mode for the ' 'input source (the parent source, ' 'or the source itself if it was ' 'not deblended) was changed to ' '"sinh" because of a ' 'nonpositive minimum data value ' 'within the input source ' 'segment.'), ), FlagDefinition( bit_value=128, name='deblend_too_many_markers', description=('deblending mode changed to linear: too ' 'many markers'), detailed_description=('The deblending mode for the ' 'input source (the parent source, ' 'or the source itself if it was ' 'not deblended) was changed to ' '"linear" because the input ' 'source had too many potential ' 'deblended sources.'), ), FlagDefinition( bit_value=256, name='undefined_shape', description=('nonpositive net flux or invalid covariance ' '(shape properties undefined)'), detailed_description=('The net source flux (the zeroth ' 'image moment over the source ' 'segment) is not positive, so the ' 'centroid and the ' 'covariance-derived shape ' 'properties (e.g., ``centroid``, ' '``semimajor_axis``, ' '``orientation``) are undefined or ' 'unreliable. The flag is also set ' 'when the second-order moments are ' 'not positive semidefinite, in ' 'which case the covariance-derived ' 'shape properties are NaN. ' 'Fully masked sources ' 'have a zero net flux, so they are ' 'also flagged.'), ), FlagDefinition( bit_value=512, name='singular_covariance', description=('singular or nearly singular source ' 'covariance'), detailed_description=('The source covariance matrix is ' 'singular or nearly singular (the ' 'minor-axis variance is below ' '``1/12``, the variance of a ' 'uniform distribution across a ' 'single pixel), so ' 'covariance-derived shape ' 'properties (e.g., ' '``semimajor_axis``, ' '``orientation``, ' '``eccentricity``) are ' 'ill-defined and have been ' 'regularized.'), ), FlagDefinition( bit_value=1024, name='kron_undefined', description='Kron aperture undefined or skipped', detailed_description=('The Kron aperture could not be ' 'defined (e.g., the source is ' 'fully masked) or the Kron ' 'photometry was skipped (e.g., the ' 'aperture was too large), so the ' 'Kron flux is NaN. The ' 'photometry-loop flags ' '(``kron_no_overlap``, ' '``kron_partial_overlap``, ' '``kron_masked_pixels``, ' '``kron_non_finite_data``, ' '``kron_non_finite_error``, ' '``kron_neighbor_pixels``, ' '``kron_uncorrected_pixels``) are ' 'not evaluated for these sources, ' 'but ``kron_minimum_radius`` is ' 'computed independently from the ' 'radii and can still be set.'), ), FlagDefinition( bit_value=2048, name='kron_minimum_radius', description=('minimum Kron radius or minimum circular ' 'radius applied'), detailed_description=('The measured unscaled Kron radius ' 'was at or below the minimum ' 'unscaled Kron radius ' '(``kron_params[1]``) or could not ' 'be measured (nonpositive Kron ' 'numerator or denominator), so the ' 'minimum was used, or the ' 'minimum circular aperture ' '(``kron_params[2]``) was used ' 'instead of the Kron ellipse.'), ), FlagDefinition( bit_value=4096, name='kron_no_overlap', description='Kron aperture fully outside the data', detailed_description=('The Kron aperture is fully ' 'outside the data array: no pixel ' 'with nonzero aperture weight ' 'falls inside the data.'), ), FlagDefinition( bit_value=8192, name='kron_partial_overlap', description='Kron aperture partially outside the data', detailed_description=('The Kron aperture is partially ' 'outside the data array: one or ' 'more pixels with nonzero aperture ' 'weight fall outside the data.'), ), FlagDefinition( bit_value=16384, name='kron_masked_pixels', description='masked pixels within the Kron aperture', detailed_description=('One or more input-masked pixels ' '(``mask`` keyword) have nonzero ' 'aperture weight within the Kron ' 'aperture.'), ), FlagDefinition( bit_value=32768, name='kron_non_finite_data', description=('nonfinite data values within the Kron ' 'aperture'), detailed_description=('One or more unmasked data values ' 'with nonzero aperture weight within ' 'the Kron aperture are nonfinite ' '(NaN or inf).'), ), FlagDefinition( bit_value=65536, name='kron_non_finite_error', description=('nonfinite error values within the Kron ' 'aperture'), detailed_description=('One or more unmasked error values ' 'with nonzero aperture weight within ' 'the Kron aperture are nonfinite ' '(NaN or inf), so the Kron flux ' 'error is NaN.'), ), FlagDefinition( bit_value=131072, name='kron_neighbor_pixels', description=('neighbor-source pixels within the Kron ' 'aperture'), detailed_description=('One or more pixels within the ' 'Kron aperture were excluded or ' 'corrected due to neighboring ' 'sources in the segmentation image ' '(``aperture_mask_method`` of ' '"mask" or "correct").'), ), FlagDefinition( bit_value=262144, name='kron_uncorrected_pixels', description=('uncorrectable neighbor pixels within the ' 'Kron aperture'), detailed_description=('With ' '``aperture_mask_method="correct"``' ', one or more neighbor-source ' 'pixels within the Kron aperture ' 'could not be corrected (the ' 'mirror pixel was unavailable) and ' 'were set to zero instead.'), ), FlagDefinition( bit_value=524288, name='local_bkg_too_few_pixels', description=('too few usable pixels to estimate the local ' 'background'), detailed_description=('The local background annulus ' '(``local_bkg_width`` > 0) contains ' 'fewer than 10 usable pixels (not ' 'masked, finite, and not within any ' 'source segment), so the local ' 'background is set to zero instead ' 'of being estimated (NaN for a ' 'fully masked source).'), ), ] domain: ClassVar = 'segmentation' # Create a singleton instance for global use SEGMENTATION_FLAGS = _SegmentationFlags() def _update_decode_docstring(func): """ Decorator to update a function docstring with the segmentation flag documentation. The ```` placeholder in the function docstring is replaced with a bullet list generated from ``SEGMENTATION_FLAGS`` (see `photutils.utils._flags.update_flag_docstring`). Parameters ---------- func : function The function to decorate. Returns ------- func : function The decorated function with updated docstring. """ return update_flag_docstring(func, SEGMENTATION_FLAGS, indent=None) @_update_decode_docstring def decode_segmentation_flags(flags, *, return_bit_values=False): # numpydoc ignore: RT05 """ Decode segmentation bitwise flag values into individual components. This function takes integer flag values from segmentation operations and returns a list of human-readable names of the conditions that were detected. 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 was detected when processing the segmentation. return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag names (strings). Default is `False`. Returns ------- decoded : list of str, list of int, or nested list List of active flag names (or bit values) for a scalar input. For an array input, a nested list with the same shape as the input is returned, where each innermost element is the list of active flag names (or bit values) for the corresponding flag. If no flags are set, an empty list is returned. Possible flags are: Examples -------- Decode a single flag value: >>> from photutils.segmentation import decode_segmentation_flags >>> issues = decode_segmentation_flags(48) # bits 16 and 32 set >>> print(issues) ['edge_touch', 'deblended'] >>> 'edge_touch' in issues True >>> 'deblended' in issues True Decode multiple flag values: >>> flags = [0, 1, 33] # 0, bit 1, bits 1+32 >>> decoded_list = decode_segmentation_flags(flags) >>> decoded_list[0] # No issues [] >>> decoded_list[1] ['masked_pixels'] >>> decoded_list[2] ['masked_pixels', 'deblended'] Return the bit values instead of the names: >>> decode_segmentation_flags(48, return_bit_values=True) [16, 32] """ return decode_flags(flags, SEGMENTATION_FLAGS, return_bit_values=return_bit_values) astropy-photutils-24be529/photutils/segmentation/tests/000077500000000000000000000000001526452531300235025ustar00rootroot00000000000000astropy-photutils-24be529/photutils/segmentation/tests/__init__.py000066400000000000000000000000001526452531300256010ustar00rootroot00000000000000astropy-photutils-24be529/photutils/segmentation/tests/_batch_scene.py000066400000000000000000000133131526452531300264520ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Shared synthetic scene for the batch-driver comparator tests. """ import numpy as np from photutils.segmentation import SourceCatalog, detect_sources from photutils.segmentation.utils import _mask_to_mirrored_value __all__ = ['make_batch_scene', 'make_catalog', 'reference_aperture_data'] def make_batch_scene(*, seed=0): """ Make a deterministic test scene exercising the batch-driver edge cases. The scene contains isolated sources, close/overlapping pairs (neighbor-segment handling), sources touching every image edge (bbox clipping), masked pixels inside sources and spanning a close pair (mirror-correction rejection), and nonfinite data values inside a source. Parameters ---------- seed : int, optional The seed for the random number generator used for the background noise. Returns ------- result : dict Keys ``data``, ``error``, ``mask``, ``segm`` (a `~photutils.segmentation.SegmentationImage`). """ rng = np.random.default_rng(seed) ny = nx = 151 yy, xx = np.mgrid[0:ny, 0:nx] data = rng.normal(0.0, 0.1, (ny, nx)) positions = [(20, 20), (24, 26), (75, 75), (75, 81), (140, 20), (5, 100), (100, 4), (147, 147), (50, 120), (120, 50), (100, 100)] for i, (xc, yc) in enumerate(positions): amp = 5.0 + i sig = 1.5 + 0.2 * (i % 4) data += amp * np.exp(-((xx - xc) ** 2 + (yy - yc) ** 2) / (2 * sig ** 2)) error = np.full((ny, nx), 0.1) error[::17, ::13] = 0.3 error[100, 102] = np.nan # nonfinite error inside a source mask = np.zeros((ny, nx), dtype=bool) mask[18:20, 22:24] = True # inside a source of a close pair mask[73:75, 72:84] = True # spans a close pair data[77, 78] = np.nan # nonfinite inside a source data[75, 83] = np.inf segm = detect_sources(data, 1.0, n_pixels=5) return {'data': data, 'error': error, 'mask': mask, 'segm': segm} def make_catalog(scene, *, aperture_mask_method='correct', with_error=True, with_mask=True): """ Make a `SourceCatalog` from a scene with the given options. Parameters ---------- scene : dict A scene from `make_batch_scene`. aperture_mask_method : {'correct', 'mask', 'none'}, optional The segmentation masking method. with_error : bool, optional Whether to pass the scene error array to the catalog. with_mask : bool, optional Whether to pass the scene mask array to the catalog. Returns ------- result : `~photutils.segmentation.SourceCatalog` The source catalog. """ return SourceCatalog( scene['data'], scene['segm'], error=scene['error'] if with_error else None, mask=scene['mask'] if with_mask else None, aperture_mask_method=aperture_mask_method) def reference_aperture_data(cat, label, x_centroid, y_centroid, aperture_bbox, local_background, *, make_error=True): """ Make cutouts of the data, error, and mask arrays for aperture photometry. This is a verbatim port of the per-source ``_make_aperture_data`` method that the batch Cython drivers replaced. It is the numerical reference for the neighbor handling of the drivers. Parameters ---------- cat : `~photutils.segmentation.SourceCatalog` The source catalog. label : int The source label. x_centroid, y_centroid : float The aperture center. aperture_bbox : `~photutils.aperture.BoundingBox` The aperture bounding box. local_background : float The local background to subtract. make_error : bool, optional Whether to return the error cutout. Returns ------- result : tuple The ``(data, error, mask, cutout_xycen, slc_sm, flag_masks)`` values, or ``(None,) * 6`` if the bounding box does not overlap the data. """ slc_lg, slc_sm = aperture_bbox.get_overlap_slices(cat._data.shape) if slc_lg is None: return (None,) * 6 data = cat._data[slc_lg].astype(float) - local_background mask_cutout = None if cat._mask is None else cat._mask[slc_lg] non_finite_mask = ~np.isfinite(data) data_mask = non_finite_mask.copy() if mask_cutout is not None: data_mask |= mask_cutout if make_error and cat._error is not None: error = cat._error[slc_lg] else: error = None cutout_xycen = (x_centroid - max(0, aperture_bbox.ixmin), y_centroid - max(0, aperture_bbox.iymin)) segm_mask = None uncorrected_mask = None if cat.aperture_mask_method == 'none': mask = data_mask else: segment_img = cat._segmentation_image.data[slc_lg] segm_mask = np.logical_and(segment_img != label, segment_img != 0) if cat.aperture_mask_method == 'mask': mask = data_mask | segm_mask else: mask = data_mask if cat.aperture_mask_method == 'correct': data, uncorrected_mask = _mask_to_mirrored_value( data, segm_mask, cutout_xycen, mask=mask, return_uncorrected=True) if error is not None: error = _mask_to_mirrored_value(error, segm_mask, cutout_xycen, mask=mask) flag_masks = {'data_mask': data_mask, 'input_mask': mask_cutout, 'non_finite_mask': non_finite_mask, 'segm_mask': segm_mask, 'uncorrected_mask': uncorrected_mask} return data, error, mask, cutout_xycen, slc_sm, flag_masks astropy-photutils-24be529/photutils/segmentation/tests/test_batch_centroid_quad.py000066400000000000000000000270551526452531300311060ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch quadratic centroid path. """ from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation._batch_catalog import batch_quad_boxes from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) def _centroid_quad_var(coeffs, xm_rel, ym_rel, pinv, box_var): # Verbatim port of the previous ``_centroid_quad_var`` method c10, c01, c11, c20, c02 = coeffs[1:] det = 4.0 * c20 * c02 - c11 * c11 grad_x = np.array( [0.0, -2.0 * c02 / det, c11 / det, (c01 + 2.0 * c11 * xm_rel) / det, -4.0 * c02 * xm_rel / det, (-2.0 * c10 - 4.0 * c20 * xm_rel) / det]) grad_y = np.array( [0.0, c11 / det, -2.0 * c20 / det, (c10 + 2.0 * c11 * ym_rel) / det, (-2.0 * c01 - 4.0 * c02 * ym_rel) / det, -4.0 * c20 * ym_rel / det]) u_x = grad_x @ pinv u_y = grad_y @ pinv var_x = np.sum(u_x**2 * box_var) var_y = np.sum(u_y**2 * box_var) cov_xy = np.sum(u_x * u_y * box_var) return var_x, var_y, cov_xy def _reference_centroid_quad_results(cat): """ Compute the quadratic centroid results of each source. This is a port of the per-source Python implementation of ``_centroid_quad_results`` that the batch path replaces, with a final column that is 1.0 where the centroid is not a quadratic fit maximum. The pixels of the 3x3 box that are input-masked or have a nonfinite data value are left out of the fit. The other pixels of the box are fit with their data values, inside or outside of the source segment. It is the numerical reference for the batch path. """ 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 full_pinv = np.linalg.pinv(coeff_matrix) compute_err = cat._error is not None _nan = np.nan nan_result = (_nan, _nan, _nan, _nan, _nan, 1.0) results = [] for cutout, error_cutout, mask, input_mask in zip( cat._data_cutouts, cat._error_cutouts, cat._cutout_total_masks, cat._mask_cutouts, strict=True): ny, nx = cutout.shape excluded = ~np.isfinite(cutout) if input_mask is not None: excluded |= input_mask if ny < 3 or nx < 3: results.append(nan_result) continue if np.all(mask): results.append(nan_result) continue data_cutout = np.array(cutout, dtype=float) data_cutout[excluded] = 0.0 cutout = np.array(cutout, dtype=float) cutout[mask] = 0.0 yidx, xidx = np.unravel_index(np.argmax(cutout), cutout.shape) if xidx == 0 or xidx == nx - 1 or yidx == 0 or yidx == ny - 1: results.append((float(xidx), float(yidx), _nan, _nan, _nan, 1.0)) continue xidx0 = xidx - 1 yidx0 = yidx - 1 cutout_flat = data_cutout[yidx0:yidx0 + 3, xidx0:xidx0 + 3].ravel() # Fit only the box pixels that are not excluded pinv = full_pinv keep = ~excluded[yidx0:yidx0 + 3, xidx0:xidx0 + 3].ravel() if not np.all(keep): if np.linalg.matrix_rank(coeff_matrix[keep]) < 6: results.append(nan_result) continue pinv = np.zeros((6, 9)) pinv[:, keep] = np.linalg.pinv(coeff_matrix[keep]) 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: results.append(nan_result) continue xm_rel = (c01 * c11 - 2.0 * c02 * c10) / det ym_rel = (c10 * c11 - 2.0 * c20 * c01) / det xm = xm_rel + xidx0 ym = ym_rel + yidx0 if not (0.0 < xm < (nx - 1.0) and 0.0 < ym < (ny - 1.0)): results.append(nan_result) continue var_x = var_y = cov_xy = _nan if compute_err: box_var = (error_cutout[yidx0:yidx0 + 3, xidx0:xidx0 + 3] .astype(float).ravel()**2) box_var[~keep] = 0.0 var_x, var_y, cov_xy = _centroid_quad_var( c, xm_rel, ym_rel, pinv, box_var) results.append((xm, ym, var_x, var_y, cov_xy, 0.0)) results = np.array(results) nan_mask = (np.isnan(results[:, 0]) | np.isnan(results[:, 1])) if np.any(nan_mask): cutout_centroid = cat._array('cutout_centroid') results[nan_mask, 0:2] = cutout_centroid[nan_mask] iso_cov = cat._centroid_err_cov results[nan_mask, 2] = iso_cov[nan_mask, 0, 0] results[nan_mask, 3] = iso_cov[nan_mask, 1, 1] results[nan_mask, 4] = iso_cov[nan_mask, 0, 1] return results @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('with_error', [True, False]) @pytest.mark.parametrize('with_mask', [True, False]) def test_matches_reference(scene, with_error, with_mask): cat = make_catalog(scene, with_error=with_error, with_mask=with_mask) expected = _reference_centroid_quad_results(cat) assert_allclose(cat._centroid_quad_results, expected, rtol=1e-12, equal_nan=True) assert_allclose(cat.centroid_quad_err[:, 0], np.sqrt(expected[:, 2]), rtol=1e-12, equal_nan=True) def _edge_case_catalog(): # Sources exercising every status of the box gather: a cutout # smaller than 3x3, a fully masked source, a peak on the cutout # edge, a negative source whose peak is a masked (zero) pixel, a # rejected (nonmaximum) fit, a normal source, and a corner peak data = np.zeros((60, 60)) yy, xx = np.mgrid[0:60, 0:60] segm_data = np.zeros(data.shape, dtype=int) error = np.full(data.shape, 0.3) mask = np.zeros(data.shape, dtype=bool) # Label 1: 2x2 segment (too small) data[2:4, 2:4] = 5.0 segm_data[2:4, 2:4] = 1 # Label 2: fully masked 5x5 segment data[2:7, 10:15] = 5.0 segm_data[2:7, 10:15] = 2 mask[2:7, 10:15] = True # Label 3: peak on the cutout edge (a ramp) data[10:15, 2:7] = xx[10:15, 2:7] segm_data[10:15, 2:7] = 3 # Label 4: negative source with one masked interior pixel, whose # zero value becomes the peak of the zero-filled cutout data[10:17, 10:17] = -5.0 - ((xx[10:17, 10:17] - 13) ** 2 + (yy[10:17, 10:17] - 13) ** 2) segm_data[10:17, 10:17] = 4 mask[12, 12] = True # Label 5: an interior peak whose 3x3 box has high corners, so the # fitted quadratic has a positive x curvature and is rejected data[22:25, 22:25] = [[8.0, 0.0, 8.0], [0.0, 10.0, 0.0], [8.0, 0.0, 8.0]] segm_data[20:27, 20:27] = 5 # Label 6: normal Gaussian source data[30:45, 30:45] = 20.0 * np.exp( -((xx[30:45, 30:45] - 37.3) ** 2 + (yy[30:45, 30:45] - 37.6) ** 2) / 8.0) segm_data[30:45, 30:45] = 6 # Label 7: peak at the cutout corner with a NaN data value data[50:55, 50:55] = 1.0 data[50, 50] = 10.0 data[52, 52] = np.nan segm_data[50:55, 50:55] = 7 return SourceCatalog(data, SegmentationImage(segm_data), error=error, mask=mask) def test_edge_cases(): cat = _edge_case_catalog() expected = _reference_centroid_quad_results(cat) assert_allclose(cat._centroid_quad_results, expected, rtol=1e-12, equal_nan=True) # Check that the intended branches were exercised: the box # statuses, the rejected fit, and the fallback to the isophotal # centroid arrays = cat._get_batch_arrays() iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() status, peak, boxes, box_var, fitted = batch_quad_boxes( arrays['data'], error=arrays['error'], mask=arrays['mask'], segm=arrays['segm'], labels=np.ascontiguousarray(cat.labels, dtype=np.intp), bbox_iymin=iymin, bbox_iymax=iymax, bbox_ixmin=ixmin, bbox_ixmax=ixmax, compute_err=1) assert_array_equal(status, [1, 2, 3, 0, 0, 0, 3]) assert_array_equal(peak[0], [-1, -1]) assert_array_equal(peak[3], [2, 2]) # the masked (zero) pixel assert np.all(boxes[3] <= 0) assert box_var[3, 4] == 0 # the masked center pixel # Only the masked center pixel is left out of the fit assert_array_equal(fitted[3], [1, 1, 1, 1, 0, 1, 1, 1, 1]) assert np.all(fitted[[0, 1, 2, 4, 5, 6]] == 1) assert np.all(box_var[5] == 0.3**2) # Edge peaks report the peak position without errors assert_allclose(cat._centroid_quad_results[2, :2], peak[2]) assert np.all(np.isnan(cat._centroid_quad_results[2, 2:5])) assert_allclose(cat._centroid_quad_results[6, :2], peak[6]) # The rejected fit falls back to the isophotal centroid assert_array_equal(peak[4], [3, 3]) assert_allclose(cat._centroid_quad_results[4, :2], cat.cutout_centroid[4]) # Only the fitted sources are not fallbacks assert_array_equal(cat.centroid_quad_fallback, ~((status == 0) & ~np.isin(np.arange(7), [4]))) def _driver_inputs(cat): arrays = cat._get_batch_arrays() iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() return {'data': arrays['data'], 'error': arrays['error'], 'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(cat.labels, dtype=np.intp), 'bbox_iymin': iymin, 'bbox_iymax': iymax, 'bbox_ixmin': ixmin, 'bbox_ixmax': ixmax, 'compute_err': 1} def _call_driver(inp): return batch_quad_boxes(inp.pop('data'), **inp) @pytest.mark.parametrize('name', ['bbox_iymin', 'bbox_iymax', 'bbox_ixmin', 'bbox_ixmax']) def test_length_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1] match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_driver(inp) @pytest.mark.parametrize('name', ['error', 'mask', 'segm']) def test_shape_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1, :] match = f'{name} must have the same shape as data' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_compute_err_without_error(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) inp['error'] = None match = 'error must be provided when compute_err is set' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_no_error_boxes(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) inp['error'] = None inp['compute_err'] = 0 _, _, _, box_var, _ = _call_driver(inp) assert np.all(box_var == 0) def test_thread_safety(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) expected = _call_driver(dict(inp)) def run(_): return _call_driver(dict(inp)) with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(run, range(8))) for result in results: for got, want in zip(result, expected, strict=True): assert_array_equal(got, want) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_centroid_win.py000066400000000000000000000377171526452531300307570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch centroid_win Cython driver. """ import math import warnings from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from astropy.stats import gaussian_fwhm_to_sigma from numpy.testing import assert_allclose from photutils.aperture._segmentation import SEG_METHOD_CODES from photutils.segmentation import SourceCatalog from photutils.segmentation._batch_catalog import batch_centroid_win from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) from photutils.segmentation.utils import _mask_to_mirrored_value def _reference_iterate_centroid_win(label, xcen, ycen, rad_hl, nan_hl_source, *, data_arr, mask_arr, error_arr, segm_data, data_shape, do_correct, do_segm_mask, compute_err, max_aper_size, aperture_mask_method): """ Compute the windowed centroid for a single source. This is a verbatim port of the per-source Python implementation that the batch Cython driver replaces. It is the numerical reference for the driver. Returns ------- result : tuple of float Tuple of (xcen, ycen, weighted_flux, cen_mom_xx, cen_mom_yy, cen_mom_xy, err_sum, err_var_x, err_var_y, err_cov_xy). """ nan_result = (np.nan, np.nan, 0.0, 0.0, 0.0, 0.0, np.nan, np.nan, np.nan, np.nan) if nan_hl_source or math.isnan(xcen) or math.isnan(ycen): return nan_result 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: return nan_result # Cache for cutout data when the integer bbox doesn't change prev_ixcen = prev_iycen = None cached_data = cached_mask = cached_var = None max_iters = 16 centroid_threshold = 0.0001 iter_ = 0 dcen = 1.0 weighted_flux = 0.0 dx_mom = dy_mom = 0.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 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 if compute_err: var = error_arr[slc_y, slc_x].astype(float)**2 if do_correct: # Pixels replaced by mirrored data values # also use the mirrored pixel's variance var = _mask_to_mirrored_value( var, segm_mask, cutout_xycen, mask=data_mask) var[data_mask] = 0.0 cached_var = var # 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 weighted_flux = np.sum(weighted) dx_mom = np.sum(weighted * xx) / weighted_flux dy_mom = np.sum(weighted * yy) / weighted_flux dcen = math.sqrt(dx_mom * dx_mom + dy_mom * dy_mom) xcen += dx_mom * 2.0 ycen += dy_mom * 2.0 iter_ += 1 # Compute the windowed central 2nd-order moments (for # the fallback checks) and the raw error # sums from the last iteration, relative to # the pre-update center. cen_mom_xx = cen_mom_yy = cen_mom_xy = 0.0 err_sum = err_var_x = err_var_y = err_cov_xy = np.nan if np.isfinite(weighted_flux) and weighted_flux > 0: cen_mom_xx = (np.sum(weighted * xx * xx) / weighted_flux - dx_mom * dx_mom) cen_mom_yy = (np.sum(weighted * yy * yy) / weighted_flux - dy_mom * dy_mom) cen_mom_xy = (np.sum(weighted * xx * yy) / weighted_flux - dx_mom * dy_mom) if compute_err: weighted_var = ((aper_weights * gweight)**2 * cached_var) err_sum = np.sum(weighted_var) err_var_x = np.sum(weighted_var * xx * xx) err_var_y = np.sum(weighted_var * yy * yy) err_cov_xy = np.sum(weighted_var * xx * yy) return (xcen, ycen, weighted_flux, cen_mom_xx, cen_mom_yy, cen_mom_xy, err_sum, err_var_x, err_var_y, err_cov_xy) @pytest.fixture(scope='module') def scene(): return make_batch_scene() def _driver_inputs(cat): """ Build the batch_centroid_win inputs for a catalog, mirroring the wiring in SourceCatalog._centroid_win_results. """ arrays = cat._get_batch_arrays() radius_hl = cat.flux_radius(0.5).value.copy() nan_hl = ~np.isfinite(radius_hl) small = np.isfinite(radius_hl) & (radius_hl < 0.5) radius_hl[small] = 0.5 sigma = 2.0 * radius_hl * gaussian_fwhm_to_sigma xcen0 = np.atleast_1d(cat.x_centroid).astype(np.float64) ycen0 = np.atleast_1d(cat.y_centroid).astype(np.float64) # np.isnan (not ~np.isfinite) matches the math.isnan checks of # _reference_iterate_centroid_win skip = (nan_hl | np.isnan(xcen0) | np.isnan(ycen0)).astype(np.uint8) return {'data': arrays['data'], 'error': arrays['error'], 'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(cat.labels, dtype=np.intp), 'xcen0': xcen0, 'ycen0': ycen0, 'sigma': sigma, 'skip': skip, 'radius_hl': radius_hl, 'nan_hl': nan_hl} @pytest.mark.parametrize('method', ['correct', 'mask', 'none']) @pytest.mark.parametrize('with_error', [True, False]) @pytest.mark.parametrize('with_mask', [True, False]) def test_matches_reference(scene, method, with_error, with_mask): cat = make_catalog(scene, aperture_mask_method=method, with_error=with_error, with_mask=with_mask) inp = _driver_inputs(cat) max_aper_size = max(scene['data'].size, 1_000_000) result = batch_centroid_win( inp['data'], error=inp['error'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], xcen0=inp['xcen0'], ycen0=inp['ycen0'], sigma=inp['sigma'], skip=inp['skip'], seg_method=SEG_METHOD_CODES[method], compute_err=int(with_error), max_aper_size=max_aper_size) assert result.shape == (len(inp['labels']), 10) ref_kwargs = { 'data_arr': scene['data'], 'mask_arr': scene['mask'] if with_mask else None, 'error_arr': scene['error'] if with_error else None, 'segm_data': scene['segm'].data, 'data_shape': scene['data'].shape, 'do_correct': method == 'correct', 'do_segm_mask': method != 'none', 'compute_err': with_error, 'max_aper_size': max_aper_size, 'aperture_mask_method': method, } for i, label in enumerate(inp['labels']): ref = _reference_iterate_centroid_win( label, inp['xcen0'][i], inp['ycen0'][i], inp['radius_hl'][i], inp['nan_hl'][i], **ref_kwargs) # atol covers cancellation-limited near-zero central moments. # Positions and flux are far from zero and are effectively # checked at rtol assert_allclose(result[i], np.array(ref), rtol=1e-12, atol=1e-10, equal_nan=True) def test_skip_rows(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) skip = np.ones_like(inp['skip']) result = batch_centroid_win( inp['data'], error=inp['error'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], xcen0=inp['xcen0'], ycen0=inp['ycen0'], sigma=inp['sigma'], skip=skip, seg_method=4, compute_err=1, max_aper_size=scene['data'].size) expected = np.array([np.nan, np.nan, 0.0, 0.0, 0.0, 0.0, np.nan, np.nan, np.nan, np.nan]) for row in result: assert_allclose(row, expected, equal_nan=True) def test_oom_guard(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) result = batch_centroid_win( inp['data'], error=inp['error'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], xcen0=inp['xcen0'], ycen0=inp['ycen0'], sigma=inp['sigma'], skip=inp['skip'], seg_method=4, compute_err=1, max_aper_size=4) assert np.all(np.isnan(result[:, 0])) def test_compute_err_without_error(scene): cat = make_catalog(scene, with_error=False) inp = _driver_inputs(cat) match = 'error must be provided when compute_err is set' with pytest.raises(ValueError, match=match): batch_centroid_win( inp['data'], error=None, mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], xcen0=inp['xcen0'], ycen0=inp['ycen0'], sigma=inp['sigma'], skip=inp['skip'], seg_method=4, compute_err=1, max_aper_size=scene['data'].size) def _call_driver(inp): return batch_centroid_win( inp['data'], error=inp['error'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], xcen0=inp['xcen0'], ycen0=inp['ycen0'], sigma=inp['sigma'], skip=inp['skip'], seg_method=4, compute_err=1, max_aper_size=inp['data'].size) @pytest.mark.parametrize('name', ['xcen0', 'ycen0', 'sigma', 'skip']) def test_length_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_driver(inp) @pytest.mark.parametrize('name', ['mask', 'segm', 'error']) def test_shape_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same shape as data' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_thread_safety(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) max_aper_size = max(scene['data'].size, 1_000_000) def run(): return batch_centroid_win( inp['data'], error=inp['error'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], xcen0=inp['xcen0'], ycen0=inp['ycen0'], sigma=inp['sigma'], skip=inp['skip'], seg_method=4, compute_err=1, max_aper_size=max_aper_size) expected = run() with ThreadPoolExecutor(max_workers=8) as executor: results = list(executor.map(lambda _: run(), range(16))) for res in results: assert_allclose(res, expected, rtol=0, atol=0, equal_nan=True) def test_catalog_centroid_win(scene): """ Test the SourceCatalog.centroid_win property against the batch driver. """ cat = make_catalog(scene) results = cat._centroid_win_results assert results.shape == (cat.n_labels, 6) # Slicing: cached values slice along the source axis sub = cat[2:5] assert_allclose(sub.centroid_win, cat.centroid_win[2:5], rtol=0, atol=0, equal_nan=True) # Scalar catalog scalar = cat[3] assert scalar.isscalar assert_allclose(np.atleast_2d(scalar.centroid_win), cat.centroid_win[3:4], rtol=0, atol=0, equal_nan=True) # The full-image cast bundle is shared by reference with slices cat._get_batch_arrays() assert cat[1:3]._batch_arrays_cache is cat._batch_arrays_cache def test_batch_arrays_shared_before_build(scene): """ Test that a catalog sliced before the batch arrays are built shares them with its parent, whichever of the two builds them first. """ cat = make_catalog(scene) child = cat[1:4] scalar = cat[2] assert 'data' not in cat._batch_arrays_cache assert child._batch_arrays_cache is cat._batch_arrays_cache assert scalar._batch_arrays_cache is cat._batch_arrays_cache # Building on a slice populates the parent (and other slices) arrays = scalar._get_batch_arrays() assert cat._batch_arrays_cache['data'] is arrays['data'] assert child._get_batch_arrays() is arrays assert cat._get_batch_arrays() is arrays # The lazily added arrays are shared the same way convdata = child._get_batch_convdata() assert cat._get_batch_convdata() is convdata def test_detection_catalog(scene): """ Test that the detection catalog is used when requested. """ cat_det = make_catalog(scene) cat = SourceCatalog(scene['data'] * 1.5, scene['segm'], error=scene['error'], mask=scene['mask'], detection_catalog=cat_det) assert_allclose(cat.centroid_win, cat_det.centroid_win, rtol=0, atol=0, equal_nan=True) def test_catalog_centroid_win_precomputed_slice(scene): """ Test that slicing a catalog after computing centroid_win is equivalent to computing centroid_win after slicing the catalog. """ cat1 = make_catalog(scene) _ = cat1.centroid_win # trigger cache, then slice cat2 = make_catalog(scene) sub2 = cat2[1:4] # slice, then compute assert_allclose(cat1[1:4].centroid_win, sub2.centroid_win, rtol=0, atol=0, equal_nan=True) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_circular.py000066400000000000000000000150721526452531300300650ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch circular photometry path. """ import warnings import numpy as np import pytest from numpy.testing import assert_allclose from photutils.aperture import CircularAperture from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog, reference_aperture_data) def _reference_aperture_to_mask(cat, aperture, **kwargs): # Verbatim port of the previous ``_aperture_to_mask`` method bbox = aperture.bbox max_size = max(cat._data.size, 1_000_000) if bbox.shape[0] * bbox.shape[1] > max_size: return None return aperture.to_mask(**kwargs) def _reference_aperture_photometry(cat, apertures, **kwargs): """ Compute the aperture flux and flux error for each source. This is a verbatim port of the per-source ``_aperture_photometry`` method that the batch Cython driver replaces. It is the numerical reference for the driver. """ flux = [] flux_err = [] for label, aperture, bkg in zip(cat.labels, apertures, cat._local_background, strict=True): if aperture is None: flux.append(np.nan) flux_err.append(np.nan) continue xcen, ycen = aperture.positions aperture_mask = _reference_aperture_to_mask(cat, aperture, **kwargs) if aperture_mask is None: flux.append(np.nan) flux_err.append(np.nan) continue data, error, mask, _, slc_sm, _ = reference_aperture_data( cat, label, xcen, ycen, aperture_mask.bbox, bkg) aperture_weights = aperture_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**2 * 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_) return np.array(flux), np.array(flux_err) def _reference_circular_photometry(cat, radius): apertures = cat._make_circular_apertures(radius) kwargs = cat._aperture_mask_kwargs['circ'] return _reference_aperture_photometry(cat, apertures, **kwargs) @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('method', ['correct', 'mask', 'none']) @pytest.mark.parametrize('with_error', [True, False]) @pytest.mark.parametrize('with_mask', [True, False]) @pytest.mark.parametrize('radius', [1.5, 4.0, 12.0]) def test_matches_reference(scene, method, with_error, with_mask, radius): cat = make_catalog(scene, aperture_mask_method=method, with_error=with_error, with_mask=with_mask) ref_flux, ref_err = _reference_circular_photometry(cat, radius) flux, flux_err = cat.circular_photometry(radius) assert_allclose(flux, ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(flux_err, ref_err, rtol=1e-12, equal_nan=True) @pytest.mark.parametrize('kwargs', [{'method': 'center'}, {'method': 'subpixel', 'subpixels': 5}]) def test_overlap_methods(scene, kwargs): cat = make_catalog(scene) cat._aperture_mask_kwargs['circ'] = kwargs ref_flux, ref_err = _reference_circular_photometry(cat, 4.0) flux, flux_err = cat.circular_photometry(4.0) assert_allclose(flux, ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(flux_err, ref_err, rtol=1e-12, equal_nan=True) def test_local_background(scene): cat = SourceCatalog(scene['data'], scene['segm'], error=scene['error'], mask=scene['mask'], local_bkg_width=6) ref_flux, ref_err = _reference_circular_photometry(cat, 4.0) flux, flux_err = cat.circular_photometry(4.0) assert_allclose(flux, ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(flux_err, ref_err, rtol=1e-12, equal_nan=True) def test_all_masked_and_nonfinite_centroid(scene): # A fully masked source has no centroid and a NaN flux. A source # whose centroid is nonfinite is also NaN data = scene['data'].copy() mask = scene['mask'].copy() segm = scene['segm'] slc = segm.slices[0] mask[slc] |= segm.data[slc] == segm.labels[0] cat = SourceCatalog(data, segm, mask=mask, error=scene['error']) ref_flux, ref_err = _reference_circular_photometry(cat, 3.0) flux, flux_err = cat.circular_photometry(3.0) assert np.isnan(flux[0]) assert np.isnan(flux_err[0]) assert_allclose(flux, ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(flux_err, ref_err, rtol=1e-12, equal_nan=True) def test_oom_guard_and_off_image(): # A circle whose bounding box exceeds the 1M-pixel guard is NaN, # and a centroid far outside the data gives a NaN flux data = np.zeros((21, 21)) data[8:13, 8:13] = 10.0 segm_data = np.zeros((21, 21), dtype=int) segm_data[8:13, 8:13] = 1 segm = SegmentationImage(segm_data) cat = SourceCatalog(data, segm) assert_allclose(cat.circular_photometry(3.0)[0], _reference_circular_photometry(cat, 3.0)[0]) flux, flux_err = cat.circular_photometry(600.0) assert np.isnan(flux[0]) assert np.isnan(flux_err[0]) cat.__dict__['x_centroid'] = np.array([500.0]) cat.__dict__['y_centroid'] = np.array([500.0]) flux, flux_err = cat.circular_photometry(3.0) assert np.isnan(flux[0]) assert np.isnan(flux_err[0]) def test_scalar_catalog(scene): cat = make_catalog(scene)[0] ref_flux, ref_err = _reference_circular_photometry(cat, 4.0) flux, flux_err = cat.circular_photometry(4.0) assert np.isscalar(flux) assert np.isscalar(flux_err) assert_allclose(flux, ref_flux[0], rtol=1e-12) assert_allclose(flux_err, ref_err[0], rtol=1e-12) def test_aperture_objects_unchanged(scene): # The public make_circular_apertures helper still returns the # aperture objects (None where undefined) cat = make_catalog(scene) apertures = cat.make_circular_apertures(3.0) assert len(apertures) == cat.n_labels assert all(isinstance(aper, CircularAperture) for aper in apertures) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_flux_radius.py000066400000000000000000000456461526452531300306200ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch flux_radius solve driver. """ import math from concurrent.futures import ThreadPoolExecutor import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from scipy.optimize import root_scalar from photutils.geometry import circular_overlap_grid from photutils.segmentation import SourceCatalog from photutils.segmentation._batch_catalog import (batch_flux_radius_prepare, batch_flux_radius_solve) from photutils.segmentation._batch_results import BatchFluxRadiusArgs from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) from photutils.segmentation.utils import _mask_to_mirrored_value # The batch solver accumulates the flux in a different order from the # reference, which perturbs the Brent iteration path, so the roots can # differ by up to the solver's absolute tolerance (xtol = 2e-12 pixels) RADIUS_ATOL = 4e-12 def _reference_optimizer_args(cat): """ Prepare the per-source flux-radius root-find arguments. This is a verbatim port of the per-source Python implementation of ``_flux_radius_optimizer_args`` that the batch Cython preparation replaces. It is the numerical reference for the preparation. """ kron_flux = cat._kron_photometry[:, 0] # unitless max_radius = cat._max_circular_kron_radius kwargs = cat._aperture_mask_kwargs['flux_radius'] 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) data_arr = cat._data mask_arr = cat._mask segm_data = cat._segmentation_image.data data_shape = data_arr.shape aperture_mask_method = cat.aperture_mask_method max_aper_size = max(data_arr.size, 1_000_000) x_centroid = np.atleast_1d(cat.x_centroid) y_centroid = np.atleast_1d(cat.y_centroid) args = [] for label, xcen, ycen, kronflux, bkg, max_radius_ in zip( cat.labels, x_centroid, y_centroid, kron_flux, cat._local_background, max_radius, strict=True): if (np.any(~np.isfinite((xcen, ycen, kronflux, max_radius_))) or kronflux == 0): args.append(None) continue 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) bbox_ny = iymax - iymin bbox_nx = ixmax - ixmin if bbox_ny * bbox_nx > max_aper_size: args.append(None) continue 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 data_mask = ~np.isfinite(cutout_data) if mask_arr is not None: data_mask |= mask_arr[slc_lg] cutout_xcen = xcen - data_xmin cutout_ycen = ycen - data_ymin 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) clean_data = cutout_data.copy() clean_data[data_mask] = 0.0 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 def _unpack_args(args): """ Convert packed ``BatchFluxRadiusArgs`` to the per-source list of ``[clean_data, grid_params, kronflux, max_radius]`` entries (or `None`) of the reference implementation. """ entries = [] for i, count in enumerate(args.counts): if count == 0: entries.append(None) continue nx = int(args.nx[i]) ny = int(args.ny[i]) start = args.starts[i] clean_data = args.values[start:start + count].reshape(ny, nx) grid_params = (*(float(edge) for edge in args.grid_edges[i]), nx, ny, args.use_exact, args.subpixels) entries.append([clean_data, grid_params, float(args.kronflux[i]), float(args.max_radius[i])]) return entries def _pack_entries(entries, *, use_exact=1, subpixels=1): """ Pack per-source reference entries (or `None`) into ``BatchFluxRadiusArgs``. """ n_src = len(entries) counts = np.zeros(n_src, dtype=np.intp) nx = np.zeros(n_src, dtype=np.intp) ny = np.zeros(n_src, dtype=np.intp) grid_edges = np.zeros((n_src, 4)) kronflux = np.zeros(n_src) max_radius = np.zeros(n_src) values = [] for i, entry in enumerate(entries): if entry is None: continue clean_data, grid_params, kronflux[i], max_radius[i] = entry counts[i] = clean_data.size ny[i], nx[i] = clean_data.shape grid_edges[i] = grid_params[:4] values.append(clean_data.ravel()) starts = np.concatenate(([0], np.cumsum(counts)[:-1])).astype(np.intp) values = (np.concatenate(values) if values else np.zeros(0, dtype=np.float64)) return BatchFluxRadiusArgs(values=values, starts=starts, counts=counts, nx=nx, ny=ny, grid_edges=grid_edges, kronflux=kronflux, max_radius=max_radius, use_exact=use_exact, subpixels=subpixels) def _solve(args, fraction): return batch_flux_radius_solve( args.values, starts=args.starts, counts=args.counts, nx=args.nx, ny=args.ny, grid_edges=args.grid_edges, kronflux=args.kronflux, max_radius=args.max_radius, fraction=fraction, use_exact=args.use_exact, subpixels=args.subpixels) def _assert_args_equal(args, expected): assert args.values.dtype == np.float64 assert args.values.flags.c_contiguous assert len(args.counts) == len(expected) entries = _unpack_args(args) for entry, ref in zip(entries, expected, strict=True): if ref is None: assert entry is None continue assert entry is not None assert_array_equal(entry[0], ref[0]) assert entry[0].dtype == np.float64 assert entry[0].flags.c_contiguous assert entry[1] == ref[1] assert entry[2] == ref[2] assert entry[3] == ref[3] def _reference_solve(args_list, fraction): # Verbatim port of the pre-change ``flux_radius`` solve loop and # ``_flux_radius_fcn``, operating on the optimizer-args entries def fcn(radius, clean_data, grid_params, normflux): 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) return 1.0 - (np.sum(clean_data * weights) / normflux) radius = [] for entry in args_list: if entry is None: radius.append(np.nan) continue clean_data, grid_params, kronflux, max_radius = entry normflux = kronflux * fraction found = False min_radius = 0.1 max_radius_delta = 0.1 * max_radius while max_radius > min_radius and found is False: try: result = root_scalar( fcn, args=(clean_data, grid_params, normflux), bracket=[min_radius, max_radius], method='brentq') result = result.root found = True except ValueError: max_radius -= max_radius_delta if found is False: result = np.nan radius.append(result) return np.array(radius) @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('method', ['correct', 'mask', 'none']) @pytest.mark.parametrize('fraction', [0.2, 0.5, 0.9]) def test_matches_reference(scene, method, fraction): cat = make_catalog(scene, aperture_mask_method=method) args = cat._flux_radius_optimizer_args expected = _reference_solve(_unpack_args(args), fraction) result = _solve(args, fraction) assert_allclose(result, expected, rtol=1e-12, atol=RADIUS_ATOL, equal_nan=True) def test_none_entries(scene): cat = make_catalog(scene) entries = _unpack_args(cat._flux_radius_optimizer_args) entries[0] = None result = _solve(_pack_entries(entries), 0.5) assert np.isnan(result[0]) assert_allclose(result, _reference_solve(entries, 0.5), rtol=1e-12, atol=RADIUS_ATOL, equal_nan=True) def test_solve_length_guard(scene): cat = make_catalog(scene) args = cat._flux_radius_optimizer_args match = 'starts must have the same length as counts' with pytest.raises(ValueError, match=match): batch_flux_radius_solve( args.values, starts=args.starts, counts=args.counts[:-1], nx=args.nx, ny=args.ny, grid_edges=args.grid_edges, kronflux=args.kronflux, max_radius=args.max_radius, fraction=0.5, use_exact=1, subpixels=1) def test_solve_grid_edges_guard(scene): cat = make_catalog(scene) args = cat._flux_radius_optimizer_args grid_edges = np.ascontiguousarray(args.grid_edges[:, :3]) match = 'grid_edges must have 4 columns' with pytest.raises(ValueError, match=match): _solve(args._replace(grid_edges=grid_edges), 0.5) def test_solve_buffer_guard(scene): """ Test that a region that runs past the end of the packed buffer, a negative start, and a negative count are each rejected """ cat = make_catalog(scene) args = cat._flux_radius_optimizer_args match = 'must lie within the values buffer' with pytest.raises(ValueError, match=match): _solve(args._replace(values=args.values[:-1]), 0.5) starts = args.starts.copy() starts[0] = -1 with pytest.raises(ValueError, match=match): _solve(args._replace(starts=starts), 0.5) counts = args.counts.copy() counts[0] = -1 with pytest.raises(ValueError, match=match): _solve(args._replace(counts=counts), 0.5) def test_all_skipped(scene): cat = make_catalog(scene) n_src = cat.n_labels args = _pack_entries([None] * n_src) assert args.values.size == 0 assert np.all(np.isnan(_solve(args, 0.5))) def test_bracket_shrink_and_no_solution(scene): # Force the shrink path. Negative data beyond a ring makes the # enclosed flux nonmonotonic, so the initial bracket has equal # signs at both ends cat = make_catalog(scene) entry = [e for e in _unpack_args(cat._flux_radius_optimizer_args) if e is not None][0] clean_data = entry[0].copy() yc = entry[0].shape[0] / 2 xc = entry[0].shape[1] / 2 yy, xx = np.mgrid[0:entry[0].shape[0], 0:entry[0].shape[1]] rr = np.hypot(xx - xc, yy - yc) clean_data[rr > 3] = -np.abs(clean_data[rr > 3]) - 1.0 forced = [[np.ascontiguousarray(clean_data), entry[1], entry[2], entry[3]]] assert_allclose(_solve(_pack_entries(forced), 0.5), _reference_solve(forced, 0.5), rtol=1e-12, atol=RADIUS_ATOL, equal_nan=True) # A milder outer ring, where shrinking the bracket does reveal a # sign change and a root is found on a later retry shrink_data = entry[0].copy() shrink_data[rr > 3] = -1.0 shrink = [[np.ascontiguousarray(shrink_data), entry[1], entry[2], entry[3]]] expected = _reference_solve(shrink, 0.5) assert np.isfinite(expected[0]) assert_allclose(_solve(_pack_entries(shrink), 0.5), expected, rtol=1e-12, atol=RADIUS_ATOL) # And a hopeless case that shrinks to no solution -> NaN hopeless = [[np.ascontiguousarray(np.full_like(clean_data, -1.0)), entry[1], entry[2], entry[3]]] assert np.isnan(_solve(_pack_entries(hopeless), 0.5)[0]) @pytest.mark.parametrize('method', ['correct', 'mask', 'none']) @pytest.mark.parametrize('with_mask', [True, False]) def test_prepare_matches_reference(scene, method, with_mask): cat = make_catalog(scene, aperture_mask_method=method, with_mask=with_mask) expected = _reference_optimizer_args(cat) assert sum(entry is not None for entry in expected) > 0 _assert_args_equal(cat._flux_radius_optimizer_args, expected) @pytest.mark.parametrize('kwargs', [{'method': 'center'}, {'method': 'subpixel', 'subpixels': 5}]) def test_prepare_overlap_methods(scene, kwargs): cat = make_catalog(scene) cat._aperture_mask_kwargs['flux_radius'] = kwargs _assert_args_equal(cat._flux_radius_optimizer_args, _reference_optimizer_args(cat)) assert_allclose(cat.flux_radius(0.5).value, _reference_solve(_reference_optimizer_args(cat), 0.5), rtol=1e-12, atol=RADIUS_ATOL, equal_nan=True) def test_prepare_local_background(scene): cat = SourceCatalog(scene['data'], scene['segm'], error=scene['error'], mask=scene['mask'], local_bkg_width=6) assert np.any(cat._local_background != 0) _assert_args_equal(cat._flux_radius_optimizer_args, _reference_optimizer_args(cat)) def test_prepare_skipped_sources(scene): # A nonfinite centroid, a zero Kron flux, and an off-image centroid # have no solution, and an oversized bounding box is skipped cat = make_catalog(scene) _ = cat._kron_photometry xcen = cat.x_centroid.copy() xcen[0] = np.nan xcen[1] = 5000.0 cat.__dict__['x_centroid'] = xcen kron = cat._kron_photometry.copy() kron[2, 0] = 0.0 cat.__dict__['_kron_photometry'] = kron expected = _reference_optimizer_args(cat) assert expected[0] is None assert expected[1] is None assert expected[2] is None _assert_args_equal(cat._flux_radius_optimizer_args, expected) cat = make_catalog(scene) _ = cat._kron_photometry cat.__dict__['_max_circular_kron_radius'] = np.full(cat.n_labels, 2000.0) assert np.all(cat._flux_radius_optimizer_args.counts == 0) assert cat._flux_radius_optimizer_args.values.size == 0 @pytest.mark.parametrize('n_threads', [2, 3, 8]) def test_n_threads(scene, n_threads): # The chunked preparation (whose packed buffers are merged with # rebased starts) and the chunked solve give identical results kwargs = {'error': scene['error'], 'mask': scene['mask']} cat1 = SourceCatalog(scene['data'], scene['segm'], **kwargs) cat2 = SourceCatalog(scene['data'], scene['segm'], n_threads=n_threads, **kwargs) # Skip the first and last sources (nonfinite centroid) so that # zero-count sources fall at the chunk boundaries for cat in (cat1, cat2): _ = cat._kron_photometry xcen = cat.x_centroid.copy() xcen[0] = np.nan xcen[-1] = np.nan cat.__dict__['x_centroid'] = xcen args1 = cat1._flux_radius_optimizer_args args2 = cat2._flux_radius_optimizer_args assert args1.counts[0] == 0 assert args1.counts[-1] == 0 assert np.any(args1.counts > 0) for name in ('values', 'starts', 'counts', 'nx', 'ny', 'grid_edges', 'kronflux', 'max_radius'): assert_array_equal(getattr(args2, name), getattr(args1, name)) assert args2.use_exact == args1.use_exact assert args2.subpixels == args1.subpixels assert_array_equal(cat2.flux_radius(0.5), cat1.flux_radius(0.5)) assert_array_equal(cat2.flux_radius(0.9), cat1.flux_radius(0.9)) def _prepare_inputs(cat): arrays = cat._get_batch_arrays() n_src = cat.n_labels return {'data': arrays['data'], 'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(cat.labels, dtype=np.intp), 'xcen': np.ascontiguousarray(cat.x_centroid, dtype=float), 'ycen': np.ascontiguousarray(cat.y_centroid, dtype=float), 'local_bkg': np.zeros(n_src), 'kronflux': np.ones(n_src), 'max_radius': np.full(n_src, 5.0), 'skip': np.zeros(n_src, dtype=np.uint8), 'seg_method': 4, 'use_exact': 1, 'subpixels': 1, 'max_aper_size': 1_000_000} def _call_prepare(inp): return batch_flux_radius_prepare(inp.pop('data'), **inp) @pytest.mark.parametrize('name', ['xcen', 'ycen', 'local_bkg', 'kronflux', 'max_radius', 'skip']) def test_prepare_length_guard(scene, name): cat = make_catalog(scene) inp = _prepare_inputs(cat) inp[name] = inp[name][:-1] match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_prepare(inp) @pytest.mark.parametrize('name', ['mask', 'segm']) def test_prepare_shape_guard(scene, name): cat = make_catalog(scene) inp = _prepare_inputs(cat) inp[name] = inp[name][:-1, :] match = f'{name} must have the same shape as data' with pytest.raises(ValueError, match=match): _call_prepare(inp) def test_prepare_thread_safety(scene): cat = make_catalog(scene) inp = _prepare_inputs(cat) expected = _call_prepare(dict(inp)) def run(_): return _call_prepare(dict(inp)) with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(run, range(8))) for result in results: _assert_args_equal(result, _unpack_args(expected)) def test_catalog_flux_radius(scene): cat = make_catalog(scene) expected = _reference_solve( _unpack_args(cat._flux_radius_optimizer_args), 0.5) result = cat.flux_radius(0.5) assert result.unit == u.pix assert_allclose(result.value, expected, rtol=1e-12, atol=RADIUS_ATOL, equal_nan=True) # Named property cat.flux_radius(0.3, name='r30') assert_allclose(cat.r30.value, _reference_solve( _unpack_args(cat._flux_radius_optimizer_args), 0.3), rtol=1e-12, atol=RADIUS_ATOL, equal_nan=True) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_kron.py000066400000000000000000000313411526452531300272270ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch Kron photometry path. """ import math import warnings import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose from photutils.aperture import BoundingBox, CircularAperture from photutils.aperture._batch_photometry import (FLAG_COL_UNCORRECTED, FLAG_COL_VALID, SHAPE_ELLIPSE, batch_aperture_sums) from photutils.geometry import circular_overlap_grid, elliptical_overlap_grid from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation.flags import SEGMENTATION_FLAGS from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog, reference_aperture_data) def _reference_kron_photometry(cat, kron_aperture): """ Compute the Kron flux, flux error, and flags for each source. This is a verbatim port of the per-source Python implementation that the batch Cython driver replaces. It is the numerical reference for the driver. Returns ------- flux, flux_err, kron_flags : tuple of `~numpy.ndarray` The Kron flux, flux error, and bitwise quality flags. """ labels = cat.labels _floor = math.floor max_size = max(cat._data.size, 1_000_000) ny_img, nx_img = cat._data.shape flux = [] flux_err = [] kron_flags = [] for label, aperture, bkg in zip(labels, kron_aperture, cat._local_background, strict=True): if aperture is None: flux.append(np.nan) flux_err.append(np.nan) kron_flags.append(SEGMENTATION_FLAGS.KRON_UNDEFINED) 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) kron_flags.append(SEGMENTATION_FLAGS.KRON_UNDEFINED) 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_value(u.radian) 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) kron_flags.append(SEGMENTATION_FLAGS.KRON_UNDEFINED) 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, flag_masks) = reference_aperture_data(cat, label, xcen, ycen, bbox, bkg) if data is None: flux.append(np.nan) flux_err.append(np.nan) kron_flags.append(SEGMENTATION_FLAGS.KRON_NO_OVERLAP) continue aperture_weights = mask_data[slc_sm] in_aperture = aperture_weights > 0 pixel_mask = in_aperture & ~mask kron_flag = 0 # The aperture bounding box extends beyond the data array. # The box corners can have zero aperture weight, so compare # the number of nonzero-weight pixels within the data to the # total number in the aperture. The overlap is partial only # if at least one nonzero-weight pixel falls both inside and # outside of the data. if (ixmin < 0 or iymin < 0 or ixmax > nx_img or iymax > ny_img): n_inside = np.count_nonzero(in_aperture) if n_inside == 0: kron_flag |= SEGMENTATION_FLAGS.KRON_NO_OVERLAP elif n_inside != np.count_nonzero(mask_data): kron_flag |= SEGMENTATION_FLAGS.KRON_PARTIAL_OVERLAP input_mask = flag_masks['input_mask'] if input_mask is not None and np.any(input_mask & in_aperture): kron_flag |= SEGMENTATION_FLAGS.KRON_MASKED_PIXELS if np.any(flag_masks['non_finite_mask'] & in_aperture): kron_flag |= SEGMENTATION_FLAGS.KRON_NON_FINITE_DATA segm_mask = flag_masks['segm_mask'] if segm_mask is not None and np.any(segm_mask & in_aperture): kron_flag |= SEGMENTATION_FLAGS.KRON_NEIGHBOR_PIXELS uncorrected_mask = flag_masks['uncorrected_mask'] if (uncorrected_mask is not None and np.any(uncorrected_mask & in_aperture)): kron_flag |= SEGMENTATION_FLAGS.KRON_UNCORRECTED_PIXELS # A nonfinite error value among the summed pixels makes the # flux error nonfinite if error is not None and not np.all(np.isfinite(error[pixel_mask])): kron_flag |= SEGMENTATION_FLAGS.KRON_NON_FINITE_ERROR kron_flags.append(kron_flag) 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**2 * 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) kron_flags = np.array(kron_flags, dtype=int) return flux, flux_err, kron_flags @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('method', ['correct', 'mask', 'none']) @pytest.mark.parametrize('with_error', [True, False]) @pytest.mark.parametrize('with_mask', [True, False]) def test_matches_reference(scene, method, with_error, with_mask): cat = make_catalog(scene, aperture_mask_method=method, with_error=with_error, with_mask=with_mask) kron_aperture = cat._array('kron_aperture') ref_flux, ref_err, ref_flags = _reference_kron_photometry( cat, kron_aperture) flux, flux_err, kron_flags = cat._calc_kron_photometry() assert_allclose(flux, ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(flux_err, ref_err, rtol=1e-12, equal_nan=True) assert np.array_equal(kron_flags, ref_flags) def test_custom_kron_params(scene): cat = make_catalog(scene) result = cat.kron_photometry((1.8, 1.0)) apertures = cat._make_kron_apertures( cat._validate_kron_params((1.8, 1.0))) ref_flux, ref_err, _ = _reference_kron_photometry(cat, apertures) assert_allclose(result[0], ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(result[1], ref_err, rtol=1e-12, equal_nan=True) def test_mixed_aperture_types(scene): # A minimum circular radius makes the sources whose scaled Kron # ellipse is smaller than that radius circular, while the larger # sources stay elliptical. Both driver groups then run in one # _calc_kron_photometry call cat = SourceCatalog(scene['data'], scene['segm'], error=scene['error'], mask=scene['mask'], aperture_mask_method='correct', kron_params=(2.5, 1.4, 5.0)) apertures = cat._array('kron_aperture') types = {type(ap).__name__ for ap in apertures if ap is not None} assert types == {'CircularAperture', 'EllipticalAperture'} ref = _reference_kron_photometry(cat, apertures) result = cat._calc_kron_photometry() for got, want in zip(result, ref, strict=True): assert_allclose(np.asarray(got, dtype=float), np.asarray(want, dtype=float), rtol=1e-12, equal_nan=True) def _make_uncorrectable_catalog(method): """ Make a catalog whose first source has a Kron aperture lying entirely on a neighboring segment. The first source's centroid is moved to the center of the neighboring segment after its Kron radius is measured, so its Kron ellipse and the mirror of every pixel across the aperture center lie on the neighboring segment. Under the 'correct' method none of them can be corrected and the aperture has no valid members. Returns ------- result : `~photutils.segmentation.SourceCatalog` The source catalog. apertures : list The reference Kron apertures for the moved centroid. """ yy, xx = np.mgrid[0:41, 0:41] data = 0.5 + 20.0 * np.exp(-((xx - 6) ** 2 + (yy - 6) ** 2) / 8.0) data += 30.0 * np.exp(-((xx - 25) ** 2 + (yy - 25) ** 2) / 50.0) segm_data = np.zeros(data.shape, dtype=int) segm_data[3:10, 3:10] = 1 segm_data[16:35, 16:35] = 2 cat = SourceCatalog(data, SegmentationImage(segm_data), error=np.full(data.shape, 0.5), aperture_mask_method=method) _ = cat.kron_radius # measure the Kron radii before moving xcen = cat.x_centroid.copy() ycen = cat.y_centroid.copy() xcen[0] = 25.0 ycen[0] = 25.0 cat.__dict__['x_centroid'] = xcen cat.__dict__['y_centroid'] = ycen apertures = cat._make_kron_apertures(cat.kron_params) assert apertures[0].a < 9 # lies within the neighboring segment return cat, apertures def test_uncorrectable_members_give_zero_flux(): cat, apertures = _make_uncorrectable_catalog('correct') # Check that the construction gives an aperture whose only members # are uncorrectable neighbor pixels aperture = apertures[0] arrays = cat._get_batch_arrays() fcounts = batch_aperture_sums( arrays['data'], arrays['error'], arrays['mask'], np.array([[25.0, 25.0]]), SHAPE_ELLIPSE, None, 0.0, 0.0, 0.0, 0.0, 1, 1, arrays['segm'], np.array([1], dtype=np.intp), 4, np.zeros(1), 0, params_per_source=np.array([[aperture.a, aperture.b, aperture.theta.to_value(u.rad)]]), ).flag_counts assert fcounts[0, FLAG_COL_VALID] == 0 assert fcounts[0, FLAG_COL_UNCORRECTED] > 0 flux, flux_err, kron_flags = cat._calc_kron_photometry() ref_flux, ref_err, ref_flags = _reference_kron_photometry( cat, apertures) # Uncorrectable neighbor pixels stay members with a value of zero, # so the flux is 0.0 rather than NaN assert flux[0] == 0.0 assert flux_err[0] == 0.0 assert (kron_flags[0] & SEGMENTATION_FLAGS.KRON_UNCORRECTED_PIXELS) != 0 assert_allclose(flux, ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(flux_err, ref_err, rtol=1e-12, equal_nan=True) assert np.array_equal(kron_flags, ref_flags) def test_uncorrectable_members_masked_give_nan_flux(): cat, apertures = _make_uncorrectable_catalog('mask') flux, flux_err, kron_flags = cat._calc_kron_photometry() ref_flux, ref_err, ref_flags = _reference_kron_photometry( cat, apertures) # The 'mask' method excludes the neighbor pixels outright, leaving # the aperture with no members at all assert np.isnan(flux[0]) assert np.isnan(flux_err[0]) assert (kron_flags[0] & SEGMENTATION_FLAGS.KRON_UNCORRECTED_PIXELS) == 0 assert_allclose(flux, ref_flux, rtol=1e-12, equal_nan=True) assert_allclose(flux_err, ref_err, rtol=1e-12, equal_nan=True) assert np.array_equal(kron_flags, ref_flags) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_kron_radius.py000066400000000000000000000301521526452531300305750ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch Kron radius Cython kernel. """ import math import warnings from concurrent.futures import ThreadPoolExecutor from unittest.mock import patch import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation._batch_catalog import batch_kron_radius from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) from photutils.segmentation.utils import _mask_to_mirrored_value def _reference_measured_kron_radius(cat): """ Compute the unscaled measured Kron radius of each source. This is a verbatim port of the per-source Python implementation of ``_measured_kron_radius`` that the batch Cython kernel replaces. It is the numerical reference for the kernel. """ scale = 6.0 xcen_arr = cat._array('x_centroid') ycen_arr = cat._array('y_centroid') a_arr = cat._array('semimajor_axis').value * scale b_arr = cat._array('semiminor_axis').value * scale theta_arr = cat._array('orientation').to_value(u.radian) cxx_arr = cat._array('ellipse_cxx').value cxy_arr = cat._array('ellipse_cxy').value cyy_arr = cat._array('ellipse_cyy').value all_masked = cat._all_masked data_full = cat._data data_shape = data_full.shape mask_full = cat._mask segm_data = cat._segmentation_image.data max_size = max(data_full.size, 1_000_000) kron_min = cat.kron_params[1] min_circ_radius = (cat.kron_params[2] if len(cat.kron_params) == 3 else 0.0) aperture_mask_method = cat.aperture_mask_method kron_radius = [] for (label, xc, yc, a, b, theta, cxx_, cxy_, cyy_, masked) in zip(cat.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 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) 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 if (ixmax - ixmin) * (iymax - iymin) > max_size: kron_radius.append(np.nan) continue 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)) data = data_full[slc_lg].astype(float) data_mask = ~np.isfinite(data) if mask_full is not None: data_mask |= mask_full[slc_lg] 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 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, :] rr_sq = cxx_ * xx * xx + cxy_ * xx * yy + cyy_ * yy * yy rr = np.sqrt(np.maximum(rr_sq, 0.0)) 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 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]) 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) @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('method', ['correct', 'mask', 'none']) @pytest.mark.parametrize('with_mask', [True, False]) def test_matches_reference(scene, method, with_mask): cat = make_catalog(scene, aperture_mask_method=method, with_mask=with_mask) expected = _reference_measured_kron_radius(cat) assert_allclose(cat._measured_kron_radius, expected, rtol=1e-12, equal_nan=True) assert np.any(np.isfinite(expected)) def test_negative_data_gives_minimum_radius(scene): # A negative background within the measurement ellipse makes the # Kron numerator or denominator nonpositive, which gives the # minimum Kron radius data = scene['data'].copy() data[scene['segm'].data == 0] = -20.0 cat = SourceCatalog(data, scene['segm'], kron_params=(2.5, 1.7)) expected = _reference_measured_kron_radius(cat) assert np.any(expected == 1.7) assert_allclose(cat._measured_kron_radius, expected, rtol=1e-12, equal_nan=True) def test_nonfinite_and_masked_sources(scene): # A completely masked source or a nonfinite centroid is NaN mask = scene['mask'].copy() segm = scene['segm'] slc = segm.slices[0] mask[slc] |= segm.data[slc] == segm.labels[0] cat = SourceCatalog(scene['data'], segm, mask=mask) xcen = cat.x_centroid.copy() xcen[1] = np.nan cat.__dict__['x_centroid'] = xcen expected = _reference_measured_kron_radius(cat) assert np.isnan(expected[0]) assert np.isnan(expected[1]) assert_allclose(cat._measured_kron_radius, expected, rtol=1e-12, equal_nan=True) @pytest.mark.parametrize('kron_params', [(2.5, 1.4, 5.0), (2.5, 1.4)]) def test_circular_fallback(scene, kron_params): # Zero semimajor and semiminor axes use the circle of minimum # radius, or give NaN if there is no minimum circular radius cat = make_catalog(scene) cat.kron_params = kron_params n_src = cat.n_labels zero = np.zeros(n_src) << u.pix cxx = np.ones(n_src) / (u.pix * u.pix) cxy = np.zeros(n_src) / (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)), patch.object(type(cat), 'ellipse_cyy', new_callable=lambda: property(lambda _self: cxx)), patch.object(type(cat), 'ellipse_cxy', new_callable=lambda: property(lambda _self: cxy))): expected = _reference_measured_kron_radius(cat) result = cat._measured_kron_radius if len(kron_params) == 3: assert np.any(np.isfinite(expected)) else: assert np.all(np.isnan(expected)) assert_allclose(result, expected, rtol=1e-12, equal_nan=True) def test_oom_guard_and_off_image(scene): cat = make_catalog(scene) n_src = cat.n_labels huge = np.full(n_src, 1e6) << u.pix with patch.object(type(cat), 'semimajor_axis', new_callable=lambda: property(lambda _self: huge)): assert np.all(np.isnan(cat._measured_kron_radius)) cat = make_catalog(scene) cat.__dict__['x_centroid'] = np.full(n_src, 5000.0) cat.__dict__['y_centroid'] = np.full(n_src, 5000.0) assert np.all(np.isnan(cat._measured_kron_radius)) def test_partially_off_image(): # An ellipse whose bounding box extends beyond every data edge data = np.zeros((15, 15)) yy, xx = np.mgrid[0:15, 0:15] data += 10.0 * np.exp(-((xx - 7) ** 2 + (yy - 7) ** 2) / 32.0) segm = SegmentationImage((data > 0.5).astype(int)) cat = SourceCatalog(data, segm) expected = _reference_measured_kron_radius(cat) assert np.isfinite(expected[0]) assert_allclose(cat._measured_kron_radius, expected, rtol=1e-12) def _driver_inputs(cat): arrays = cat._get_batch_arrays() n_src = cat.n_labels return {'data': arrays['data'], 'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(cat.labels, dtype=np.intp), 'xcen': np.ascontiguousarray(cat.x_centroid, dtype=float), 'ycen': np.ascontiguousarray(cat.y_centroid, dtype=float), 'semimajor': np.ascontiguousarray(cat.semimajor_axis.value), 'semiminor': np.ascontiguousarray(cat.semiminor_axis.value), 'theta': np.ascontiguousarray( cat.orientation.to_value(u.radian)), 'cxx': np.ascontiguousarray(cat.ellipse_cxx.value), 'cxy': np.ascontiguousarray(cat.ellipse_cxy.value), 'cyy': np.ascontiguousarray(cat.ellipse_cyy.value), 'skip': np.zeros(n_src, dtype=np.uint8), 'seg_method': 4, 'scale': 6.0, 'min_circ_radius': 0.0, 'max_aper_size': 1_000_000} def _call_driver(inp): return batch_kron_radius(inp.pop('data'), **inp) @pytest.mark.parametrize('name', ['xcen', 'ycen', 'semimajor', 'semiminor', 'theta', 'cxx', 'cxy', 'cyy', 'skip']) def test_length_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1] match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_driver(inp) @pytest.mark.parametrize('name', ['mask', 'segm']) def test_shape_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1, :] match = f'{name} must have the same shape as data' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_skip_rows(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) inp['skip'][0] = 1 result = _call_driver(inp) assert np.all(np.isnan(result[0])) assert np.all(np.isfinite(result[1:])) def test_thread_safety(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) expected = _call_driver(dict(inp)) def run(_): return _call_driver(dict(inp)) with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(run, range(8))) for result in results: assert_allclose(result, expected, rtol=0, atol=0) def test_catalog_kron_radius(scene): # The catalog kron_radius applies the minimum radius and the # measurement-scale limit to the measured value cat = make_catalog(scene) measured = _reference_measured_kron_radius(cat) expected = measured.copy() expected[expected > 6.0] = np.nan expected[expected < cat.kron_params[1]] = cat.kron_params[1] assert_allclose(cat.kron_radius.value, expected, rtol=1e-12, equal_nan=True) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_local_background.py000066400000000000000000000476621526452531300315640ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch local background kernel. """ from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose, assert_array_equal from photutils.background import SExtractorBackground from photutils.segmentation import (SEGMENTATION_FLAGS, SegmentationImage, SourceCatalog, detect_sources) from photutils.segmentation._batch_catalog import batch_local_background from photutils.segmentation.tests._batch_scene import make_batch_scene def _annulus_values(cat): """ Gather the usable local background annulus values of each source with per-source aperture masks. This is a port of the pixel selection of the previous per-source loop that the batch kernel replaces, with the cutout data mask inlined and the aperture weights applied to the usable pixels only (the previous in-place multiplication of the whole cutout raised a warning for nonfinite data within the aperture). """ values = [] for aperture in cat.local_background_aperture: aperture_mask = aperture.to_mask(method='center') slc_lg, slc_sm = aperture_mask.get_overlap_slices(cat._data.shape) data_cutout = cat._data[slc_lg].astype(float, copy=True) segm_mask_cutout = cat._segmentation_image.data[slc_lg].astype(bool) data_mask_cutout = ~np.isfinite(data_cutout) if cat._mask is not None: data_mask_cutout |= cat._mask[slc_lg] data_mask_cutout |= segm_mask_cutout aperweight_cutout = aperture_mask.data[slc_sm] good_mask = (aperweight_cutout > 0) & ~data_mask_cutout values.append(data_cutout[good_mask] * aperweight_cutout[good_mask]) return values def _reference_local_background(cat, *, sigma=3.0, maxiters=20): """ Compute the local background of each source as the previous per-source loop did, with `SExtractorBackground` and its sigma clipping. The sigma clipping goes through astropy's fast C implementation, whose surviving values are the values within the final clipping bounds. It is the reference for the kernel. """ sigma_clip = SigmaClip(sigma=sigma, cenfunc='median', maxiters=maxiters) bkg_func = SExtractorBackground(sigma_clip=sigma_clip) local_bkgs = [] for data_values in _annulus_values(cat): 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[cat._all_masked] = np.nan return local_bkgs def _iterative_local_background(cat, *, sigma=3.0, maxiters=20): """ Compute the local background of each source with iterative-removal sigma clipping, where a value dropped in an earlier iteration is never included again. This is the ``axis=None, masked=False`` code path of `~astropy.stats.SigmaClip`. It differs from the reference only when the clipping bounds widen between iterations. """ sigma_clip = SigmaClip(sigma=sigma, cenfunc='median', maxiters=maxiters) local_bkgs = [] for data_values in _annulus_values(cat): if len(data_values) < 10: local_bkgs.append(0.0) continue values = sigma_clip(data_values, axis=None, masked=False) median = np.median(values) mean = np.mean(values) std = np.std(values) if std == 0: local_bkgs.append(mean) elif abs(mean - median) / std >= 0.3: local_bkgs.append(median) else: local_bkgs.append(2.5 * median - 1.5 * mean) local_bkgs = np.array(local_bkgs) local_bkgs[cat._all_masked] = np.nan return local_bkgs def _kernel_local_background(cat, **kwargs): """ Call the kernel directly with the catalog batch arrays. """ arrays = cat._get_batch_arrays() iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() params = {'width': cat.local_bkg_width, 'scale': 1.5, 'sigma': 3.0, 'maxiters': 20, 'min_pixels': 10} params.update(kwargs) return batch_local_background( arrays['data'], mask=arrays['mask'], segm=arrays['segm'], bbox_iymin=iymin, bbox_iymax=iymax, bbox_ixmin=ixmin, bbox_ixmax=ixmax, **params)[0] @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('width', [1, 3, 8, 24]) @pytest.mark.parametrize('with_mask', [True, False]) def test_matches_reference(scene, width, with_mask): # The scene has sources touching every image edge, close pairs, # masked pixels, and nonfinite data values cat = SourceCatalog(scene['data'], scene['segm'], error=scene['error'], mask=scene['mask'] if with_mask else None, local_bkg_width=width) expected = _reference_local_background(cat) assert np.all(np.isfinite(expected)) assert np.any(expected != 0) # The reference sums the values in a different order, so the # results agree to rounding assert_allclose(cat.local_background, expected, rtol=1e-13, atol=0) assert_allclose(_kernel_local_background(cat), expected, rtol=1e-13, atol=0) @pytest.mark.parametrize('width', [1, 3, 8, 24]) @pytest.mark.parametrize('with_mask', [True, False]) def test_n_pixels_matches_reference(scene, width, with_mask): """ Test that the kernel counts the same usable annulus pixels as the per-source aperture masks. """ cat = SourceCatalog(scene['data'], scene['segm'], mask=scene['mask'] if with_mask else None, local_bkg_width=width) expected = [len(values) for values in _annulus_values(cat)] assert_array_equal(cat._local_bkg_n_pixels, expected) def _make_noise_scene(seed): """ Make sources of varied sizes on a sloped noisy background with outliers, so that the clipping iterates and the estimator branches are exercised. """ rng = np.random.default_rng(seed) ny = nx = 121 yy, xx = np.mgrid[0:ny, 0:nx] data = rng.normal(0.0, 1.0, (ny, nx)) + 0.01 * xx + 0.02 * yy data[rng.random((ny, nx)) < 0.01] += 20.0 for _ in range(12): xc, yc = rng.uniform(5, nx - 5, 2) sig = rng.uniform(1.0, 4.0) amp = rng.uniform(20.0, 200.0) data += amp * np.exp(-((xx - xc) ** 2 + (yy - yc) ** 2) / (2 * sig ** 2)) segm = detect_sources(data, 5.0, n_pixels=5) mask = rng.random((ny, nx)) < 0.02 return data, segm, mask @pytest.mark.parametrize('seed', [1, 2, 3]) def test_matches_reference_noise_scene(seed): data, segm, mask = _make_noise_scene(seed) for width in (2, 6): cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=width) expected = _reference_local_background(cat) assert_allclose(cat.local_background, expected, rtol=1e-13, atol=0) @pytest.mark.parametrize('seed', [1, 2, 3]) def test_matches_reference_quantized_scene(seed): # Integer-valued data (e.g., raw counts) has many repeated values, # so the median jumps between iterations and the clipping bounds # can widen. The reference then includes values clipped in an # earlier iteration again, and the kernel must do the same. data, segm, mask = _make_noise_scene(seed) data = np.round(data * 4) for width in (2, 8, 24): cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=width) expected = _reference_local_background(cat) assert_allclose(cat.local_background, expected, rtol=1e-13, atol=0) def test_final_bounds_readmit_clipped_value(): # A small case where the clipping bounds widen. Iteration 3 drops # the value -1 at a lower bound of -0.84, then the median of the # remaining values falls from 1.5 to 1 while the standard deviation # shrinks only a little, so the final lower bound is -1.03 and the # -1 lies within the final bounds. The kernel must match the # reference, which includes the -1 again, rather than the # iterative-removal result. values = np.array([-1] + [0] * 8 + [1] * 2 + [2] * 2 + [3] * 7 + [4] * 2 + [8, 29], dtype=float) sigma = 1.5 shape = (21, 21) segm_data = np.zeros(shape, dtype=int) segm_data[9:12, 9:12] = 1 segm = SegmentationImage(segm_data) annulus = _annulus_pixels(9, 12, 9, 12, 3, shape) iy, ix = np.nonzero(annulus) assert iy.size > values.size rng = np.random.default_rng(0) order = rng.permutation(values.size) data = np.zeros(shape) data[iy[:values.size], ix[:values.size]] = values[order] mask = np.ones(shape, dtype=bool) mask[iy[:values.size], ix[:values.size]] = False mask[9:12, 9:12] = False # the source segment cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=3) sigma_clip = SigmaClip(sigma=sigma, cenfunc='median', maxiters=20) clipped = sigma_clip(values, axis=None, masked=False) assert clipped.min() == 0.0 clipped = sigma_clip(values, axis=None, masked=True).compressed() assert clipped.min() == -1.0 expected = SExtractorBackground(sigma_clip=sigma_clip)(values) iterative = _iterative_local_background(cat, sigma=sigma)[0] assert not np.isclose(expected, iterative) assert_allclose(_reference_local_background(cat, sigma=sigma)[0], expected, rtol=1e-13, atol=0) assert_allclose(_kernel_local_background(cat, sigma=sigma)[0], expected, rtol=1e-13, atol=0) def test_maxiters_reached(): # With a single iteration, the outliers of the noise scene are # still being clipped when maxiters is reached, so the statistics # of the values within the final bounds are computed anew data, segm, mask = _make_noise_scene(1) cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=6) result = _kernel_local_background(cat, maxiters=1) expected = _reference_local_background(cat, maxiters=1) assert_allclose(result, expected, rtol=1e-13, atol=0) assert not np.allclose(result, _kernel_local_background(cat)) def test_all_values_dropped(): # Ten annulus pixels of two values in equal number have a median # halfway between them and a standard deviation of half of their # difference, so a sigma below 1 drops every value in the first # iteration. Those bounds are final whether or not maxiters is # reached, and no value lies within them. shape = (11, 11) segm_data = np.zeros(shape, dtype=int) segm_data[4:7, 4:7] = 1 segm = SegmentationImage(segm_data) annulus = _annulus_pixels(4, 7, 4, 7, 2, shape) iy, ix = np.nonzero(annulus) data = np.zeros(shape) data[iy[:10], ix[:10]] = np.tile([0.0, 1.0], 5) mask = np.ones(shape, dtype=bool) mask[iy[:10], ix[:10]] = False mask[4:7, 4:7] = False # the source segment cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=2) for maxiters in (1, 20): result = _kernel_local_background(cat, sigma=0.1, maxiters=maxiters) assert np.isnan(result[0]) # A sigma of 1 keeps the two middle values, which lie exactly one # standard deviation from the median assert _kernel_local_background(cat, sigma=1.0)[0] == 0.5 def test_zero_width(scene): cat = SourceCatalog(scene['data'], scene['segm'], local_bkg_width=0) assert_array_equal(cat.local_background, np.zeros(cat.n_labels)) def test_all_masked_source(scene): segm = scene['segm'] mask = scene['mask'].copy() slc = segm.slices[0] mask[slc] |= segm.data[slc] == segm.labels[0] cat = SourceCatalog(scene['data'], segm, mask=mask, local_bkg_width=5) result = cat.local_background assert cat._all_masked[0] assert np.isnan(result[0]) assert np.all(np.isfinite(result[1:])) # The kernel itself still measures the annulus of the masked source assert np.isfinite(_kernel_local_background(cat)[0]) def test_few_pixels(): # Fewer than min_pixels usable annulus pixels gives zero 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) mask = np.ones((11, 11), dtype=bool) mask[3:8, 3:8] = False # the inner rectangle only, no annulus pixel cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=2) assert cat.local_background[0] == 0.0 assert cat._local_bkg_n_pixels[0] == 0 assert cat.flags[0] & SEGMENTATION_FLAGS.LOCAL_BKG_TOO_FEW_PIXELS assert _reference_local_background(cat)[0] == 0.0 assert _kernel_local_background(cat, min_pixels=1)[0] == 0.0 # No usable pixel at all gives NaN when min_pixels allows it assert np.isnan(_kernel_local_background(cat, min_pixels=0)[0]) # A single usable annulus pixel is measured only when min_pixels # allows it mask[2, 5] = False data[2, 5] = 2.0 cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=2) assert cat.local_background[0] == 0.0 assert cat._local_bkg_n_pixels[0] == 1 assert cat.flags[0] & SEGMENTATION_FLAGS.LOCAL_BKG_TOO_FEW_PIXELS assert _reference_local_background(cat)[0] == 0.0 assert _kernel_local_background(cat, min_pixels=2)[0] == 0.0 assert _kernel_local_background(cat, min_pixels=1)[0] == 2.0 def _annulus_pixels(ixmin, ixmax, iymin, iymax, width, shape, *, scale=1.5): """ Return the (y, x) indices of the pixel centers within the local background annulus of a segment bounding box, computed directly from the annulus definition. """ xpos = 0.5 * (ixmin + ixmax - 1) ypos = 0.5 * (iymin + iymax - 1) half_w_in = 0.5 * (ixmax - ixmin) * scale half_h_in = 0.5 * (iymax - iymin) * scale half_w_out = half_w_in + width half_h_out = half_h_in + width yy, xx = np.mgrid[0:shape[0], 0:shape[1]] dx = np.abs(xx - xpos) dy = np.abs(yy - ypos) outer = (dx < half_w_out) & (dy < half_h_out) inner = (dx < half_w_in) & (dy < half_h_in) return outer & ~inner @pytest.mark.parametrize(('nx_src', 'ny_src', 'width'), [(3, 3, 1), (2, 3, 1), (4, 2, 2), (5, 5, 3)]) def test_annulus_pixels_and_median(nx_src, ny_src, width): # Small annuli with even and odd pixel counts, with the pixel # membership checked against the annulus definition and the # estimator against the SExtractor mode of the pixel values shape = (21, 21) ixmin, iymin = 8, 7 ixmax, iymax = ixmin + nx_src, iymin + ny_src rng = np.random.default_rng(nx_src * 10 + ny_src) data = rng.normal(10.0, 1.0, shape) segm_data = np.zeros(shape, dtype=int) segm_data[iymin:iymax, ixmin:ixmax] = 1 segm = SegmentationImage(segm_data) cat = SourceCatalog(data, segm, local_bkg_width=width) annulus = _annulus_pixels(ixmin, ixmax, iymin, iymax, width, shape) values = data[annulus] assert values.size >= 10 aperture_mask = cat.local_background_aperture[0].to_mask( method='center') assert_array_equal(aperture_mask.to_image(shape) > 0, annulus) sigma_clip = SigmaClip(sigma=3.0, cenfunc='median', maxiters=20) expected = SExtractorBackground(sigma_clip=sigma_clip)(values) assert_allclose(cat.local_background[0], expected, rtol=1e-13, atol=0) def test_estimator_branches(): # A constant annulus (zero standard deviation) gives the mean, and # a strongly skewed annulus gives the median; both are exact shape = (31, 31) segm_data = np.zeros(shape, dtype=int) segm_data[13:18, 13:18] = 1 segm = SegmentationImage(segm_data) data = np.full(shape, 3.0) cat = SourceCatalog(data, segm, local_bkg_width=4) assert cat.local_background[0] == 3.0 data = np.zeros(shape) data[::2, ::2] = 1.5 cat = SourceCatalog(data, segm, local_bkg_width=4) annulus = _annulus_pixels(13, 18, 13, 18, 4, shape) values = data[annulus] assert np.all(np.abs(values - np.median(values)) <= 3 * np.std(values)) assert abs(np.mean(values) - np.median(values)) / np.std(values) >= 0.3 assert cat.local_background[0] == np.median(values) def test_clipping_removes_outliers(): # Outliers in the annulus are clipped and do not bias the result shape = (41, 41) segm_data = np.zeros(shape, dtype=int) segm_data[18:23, 18:23] = 1 segm = SegmentationImage(segm_data) rng = np.random.default_rng(0) data = rng.normal(5.0, 0.1, shape) data[10, 10] = 1000.0 data[30, 30] = -1000.0 cat = SourceCatalog(data, segm, local_bkg_width=8) assert_allclose(cat.local_background[0], 5.0, atol=0.05) expected = _reference_local_background(cat) assert_allclose(cat.local_background, expected, rtol=1e-13, atol=0) def test_sliced_and_scalar_catalog(scene): cat = SourceCatalog(scene['data'], scene['segm'], mask=scene['mask'], local_bkg_width=6) expected = cat.local_background assert_array_equal(cat[2:5].local_background, expected[2:5]) assert_array_equal(cat[3].local_background, expected[3:4]) def test_input_dtypes(scene): # The kernel reads float32 data and int32 segmentation images # directly, with the same results as the same values input as # float64 and intp (to within rounding, because the compiler may # fuse multiply-adds differently in the two specializations) data32 = scene['data'].astype(np.float32) segm32 = SegmentationImage(scene['segm'].data.astype(np.int32)) cat = SourceCatalog(data32, segm32, local_bkg_width=8) arrays = cat._get_batch_arrays() assert arrays['data'].dtype == np.float32 assert arrays['segm'].dtype == np.int32 segm_ref = SegmentationImage(scene['segm'].data.astype(np.intp)) cat_ref = SourceCatalog(data32.astype(np.float64), segm_ref, local_bkg_width=8) assert_allclose(cat.local_background, cat_ref.local_background, rtol=1e-13, atol=0) assert_allclose(_kernel_local_background(cat), _kernel_local_background(cat_ref), rtol=1e-13, atol=0) def test_invalid_inputs(scene): cat = SourceCatalog(scene['data'], scene['segm'], local_bkg_width=3) arrays = cat._get_batch_arrays() iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() kwargs = {'width': 3, 'scale': 1.5, 'sigma': 3.0, 'maxiters': 20, 'min_pixels': 10} match = 'bbox_ixmax must have the same length as bbox_iymin' with pytest.raises(ValueError, match=match): batch_local_background(arrays['data'], mask=arrays['mask'], segm=arrays['segm'], bbox_iymin=iymin, bbox_iymax=iymax, bbox_ixmin=ixmin, bbox_ixmax=ixmax[:-1], **kwargs) match = 'mask must have the same shape as data' with pytest.raises(ValueError, match=match): batch_local_background(arrays['data'], mask=arrays['mask'][1:], segm=arrays['segm'], bbox_iymin=iymin, bbox_iymax=iymax, bbox_ixmin=ixmin, bbox_ixmax=ixmax, **kwargs) match = 'maxiters must be at least 1' with pytest.raises(ValueError, match=match): _kernel_local_background(cat, maxiters=0) def test_n_threads(scene): cat = SourceCatalog(scene['data'], scene['segm'], mask=scene['mask'], local_bkg_width=6) cat_threaded = SourceCatalog(scene['data'], scene['segm'], mask=scene['mask'], local_bkg_width=6, n_threads=4) assert cat.n_labels >= 4 assert_array_equal(cat_threaded.local_background, cat.local_background) def test_thread_safety(scene): cat = SourceCatalog(scene['data'], scene['segm'], mask=scene['mask'], local_bkg_width=6) expected = _kernel_local_background(cat) with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(lambda _: _kernel_local_background(cat), range(8))) for result in results: assert_array_equal(result, expected) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_minmax_index.py000066400000000000000000000172371526452531300307460ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch minimum and maximum value position kernel. """ from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation._batch_catalog import batch_minmax_index from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) def _reference_cutout_index(cat, argfunc): """ The cutout-frame position of an extreme value of each source. This is a verbatim port of the per-source ``cutout_min_value_index`` and ``cutout_max_value_index`` loops that the batch kernel replaces. It is the reference for the kernel. """ idx = [] for arr in cat._array('data_cutout_masked'): if np.all(arr.mask): idx.append((np.nan, np.nan)) else: idx.append(np.unravel_index(argfunc(arr), arr.shape)) return np.array(idx) def _reference_index(cat, cutout_index): """ The verbatim port of the previous ``min_value_index`` and ``max_value_index`` loops, adding the bounding-box origin. """ out = [] for idx, slc in zip(cutout_index, cat.slices, strict=True): out.append((idx[0] + slc[0].start, idx[1] + slc[1].start)) return np.array(out) def _assert_index_equal(result, expected): assert result.shape == expected.shape assert result.dtype.kind == expected.dtype.kind assert_array_equal(result, expected) @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('with_mask', [True, False]) def test_matches_reference(scene, with_mask): cat = make_catalog(scene, with_mask=with_mask) cutout_min = _reference_cutout_index(cat, np.argmin) cutout_max = _reference_cutout_index(cat, np.argmax) _assert_index_equal(cat.cutout_min_value_index, cutout_min) _assert_index_equal(cat.cutout_max_value_index, cutout_max) _assert_index_equal(cat.min_value_index, _reference_index(cat, cutout_min)) _assert_index_equal(cat.max_value_index, _reference_index(cat, cutout_max)) assert_array_equal(cat.min_value_xindex, cat.min_value_index[:, 1]) assert_array_equal(cat.min_value_yindex, cat.min_value_index[:, 0]) assert_array_equal(cat.max_value_xindex, cat.max_value_index[:, 1]) assert_array_equal(cat.max_value_yindex, cat.max_value_index[:, 0]) # Integer positions when no source is completely masked assert cat.min_value_index.dtype.kind == 'i' # The positions hold the extreme values data = scene['data'] ymin, xmin = cat.min_value_index.T ymax, xmax = cat.max_value_index.T assert_array_equal(data[ymin, xmin], cat.min_value) assert_array_equal(data[ymax, xmax], cat.max_value) def test_ties_and_all_masked(): """ Test the first occurrence of a repeated extreme, a completely masked source (NaN positions and a float result), and a source whose extremes lie on the bounding-box edges. """ data = np.zeros((30, 30)) segm_data = np.zeros(data.shape, dtype=int) mask = np.zeros(data.shape, dtype=bool) # Label 1: constant source, so every pixel ties data[2:6, 2:7] = 4.0 segm_data[2:6, 2:7] = 1 # Label 2: completely masked data[10:14, 10:14] = 1.0 segm_data[10:14, 10:14] = 2 mask[10:14, 10:14] = True # Label 3: minimum at the top-left and maximum at the bottom-right # corner of the bounding box, with a masked would-be maximum yy, xx = np.mgrid[0:6, 0:8] data[18:24, 18:26] = xx + yy data[20, 21] = 100.0 mask[20, 21] = True segm_data[18:24, 18:26] = 3 # Label 4: repeated minimum whose first occurrence is not first in # the (nonrectangular) segment's bounding box data[2:7, 15:22] = 5.0 data[3, 16] = -1.0 data[5, 20] = -1.0 segm_data[2:7, 15:22] = 4 segm_data[2, 15:17] = 0 cat = SourceCatalog(data, SegmentationImage(segm_data), mask=mask) cutout_min = _reference_cutout_index(cat, np.argmin) cutout_max = _reference_cutout_index(cat, np.argmax) _assert_index_equal(cat.cutout_min_value_index, cutout_min) _assert_index_equal(cat.cutout_max_value_index, cutout_max) _assert_index_equal(cat.min_value_index, _reference_index(cat, cutout_min)) _assert_index_equal(cat.max_value_index, _reference_index(cat, cutout_max)) # A completely masked source gives NaN positions and a float array assert cat.min_value_index.dtype.kind == 'f' assert np.all(np.isnan(cat.min_value_index[1])) assert np.all(np.isnan(cat.cutout_max_value_index[1])) # The constant source reports its first pixel assert_array_equal(cat.cutout_min_value_index[0], [0, 0]) assert_array_equal(cat.cutout_max_value_index[0], [0, 0]) # The masked would-be maximum is skipped assert_array_equal(cat.max_value_index[2], [23, 25]) assert_array_equal(cat.min_value_index[2], [18, 18]) assert_array_equal(cat.min_value_index[3], [3, 16]) def test_sliced_and_scalar_catalog(scene): parent = make_catalog(scene) expected_min = parent.min_value_index expected_cutout_max = parent.cutout_max_value_index child = parent[[4, 1, 6]] assert_array_equal(child.min_value_index, expected_min[[4, 1, 6]]) assert_array_equal(child.cutout_max_value_index, expected_cutout_max[[4, 1, 6]]) fresh = make_catalog(scene)[[4, 1, 6]] assert_array_equal(fresh.min_value_index, expected_min[[4, 1, 6]]) scalar = make_catalog(scene)[3] assert scalar.isscalar assert_array_equal(scalar.min_value_index, expected_min[3]) assert_array_equal(scalar.cutout_max_value_index, expected_cutout_max[3]) assert scalar.min_value_xindex == expected_min[3, 1] assert scalar.max_value_yindex == parent.max_value_index[3, 0] def _driver_inputs(cat): arrays = cat._get_batch_arrays() iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() return {'values': arrays['data'], 'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(cat.labels, dtype=np.intp), 'bbox_iymin': iymin, 'bbox_iymax': iymax, 'bbox_ixmin': ixmin, 'bbox_ixmax': ixmax} def _call_driver(inp): return batch_minmax_index(inp.pop('values'), **inp) def test_driver_outputs(scene): cat = make_catalog(scene) index = _call_driver(_driver_inputs(cat)) assert index.shape == (cat.n_labels, 4) assert index.dtype == np.intp assert_array_equal(index[:, :2], cat.cutout_min_value_index) assert_array_equal(index[:, 2:], cat.cutout_max_value_index) @pytest.mark.parametrize('name', ['bbox_iymin', 'bbox_iymax', 'bbox_ixmin', 'bbox_ixmax']) def test_length_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1] match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_driver(inp) @pytest.mark.parametrize('name', ['mask', 'segm']) def test_shape_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1, :] match = f'{name} must have the same shape as values' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_thread_safety(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) expected = _call_driver(dict(inp)) def run(_): return _call_driver(dict(inp)) with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(run, range(8))) for result in results: assert_array_equal(result, expected) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_moments.py000066400000000000000000000403751526452531300277470ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch moments Cython kernels. """ from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest from numpy.testing import assert_allclose from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation._batch_catalog import (batch_central_moments, batch_moment_err, batch_raw_moments) from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) def _moment_cutout(cat, i): # The _moment_data_cutouts zeroing rules, per source slc = cat.slices[i] conv = cat._convolved_data[slc] segm = cat._segmentation_image.data[slc] bad = (~np.isfinite(conv) | (conv < 0) | (segm != cat.labels[i])) if cat._mask is not None: bad |= cat._mask[slc] cutout = conv.astype(float).copy() cutout[bad] = 0.0 return cutout def _reference_raw(cat): result = [] for i in range(cat.n_labels): arr = _moment_cutout(cat, i) 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) def _reference_central(cat, xcen, ycen): result = [] for i in range(cat.n_labels): arr = _moment_cutout(cat, i) ny, nx = arr.shape yc = np.arange(ny, dtype=float) - ycen[i] xc = np.arange(nx, dtype=float) - xcen[i] 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) def _driver_inputs(cat): arrays = cat._get_batch_arrays() convdata = np.ascontiguousarray(cat._convolved_data, dtype=np.float64) slices = cat.slices return { 'convdata': convdata, 'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(np.atleast_1d(cat.labels), dtype=np.intp), 'bbox_iymin': np.array([s[0].start for s in slices], dtype=np.intp), 'bbox_iymax': np.array([s[0].stop for s in slices], dtype=np.intp), 'bbox_ixmin': np.array([s[1].start for s in slices], dtype=np.intp), 'bbox_ixmax': np.array([s[1].stop for s in slices], dtype=np.intp), } def _reference_moment_err(cat, xcen, ycen): # The _centroid_err_cov accumulation rules, per source result = [] for i in range(cat.n_labels): slc = cat.slices[i] moment_data = _moment_cutout(cat, i) err_sq = cat._error[slc].astype(float) ** 2 total_mask = ((cat._segmentation_image.data[slc] != cat.labels[i]) | ~np.isfinite(cat._data[slc])) if cat._mask is not None: total_mask |= cat._mask[slc] err_sq[total_mask | (moment_data == 0)] = 0.0 yy, xx = np.mgrid[0:err_sq.shape[0], 0:err_sq.shape[1]] dx = xx - xcen[i] dy = yy - ycen[i] result.append((np.sum(err_sq), np.sum(err_sq * dx ** 2), np.sum(err_sq * dy ** 2), np.sum(err_sq * dx * dy))) return np.array(result) def _cutout_centroids(raw): with np.errstate(invalid='ignore', divide='ignore'): xcen = raw[:, 0, 1] / raw[:, 0, 0] ycen = raw[:, 1, 0] / raw[:, 0, 0] return xcen, ycen def _call_raw(inp): return batch_raw_moments(inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax']) @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('with_mask', [True, False]) def test_raw_moments(scene, with_mask): cat = make_catalog(scene, with_mask=with_mask) inp = _driver_inputs(cat) result = _call_raw(inp) assert result.shape == (cat.n_labels, 4, 4) assert_allclose(result, _reference_raw(cat), rtol=1e-12, atol=1e-10, equal_nan=True) def test_central_moments(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) raw = _reference_raw(cat) xcen, ycen = _cutout_centroids(raw) assert np.all(np.isfinite(xcen)) assert np.all(np.isfinite(ycen)) result = batch_central_moments( inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax'], xcen=xcen, ycen=ycen) assert_allclose(result, _reference_central(cat, xcen, ycen), rtol=1e-12, atol=1e-10, equal_nan=True) def test_central_moments_nan_centroid(scene): # A fully-masked source has zero total flux -> NaN centroid. The # central moments are then NaN everywhere except [0, 0], which # holds the (zero) flux sum cat = make_catalog(scene) inp = _driver_inputs(cat) n = cat.n_labels xcen = np.full(n, np.nan) ycen = np.full(n, np.nan) result = batch_central_moments( inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax'], xcen=xcen, ycen=ycen) expected = _reference_central(cat, xcen, ycen) assert_allclose(result, expected, rtol=1e-12, atol=1e-10, equal_nan=True) # The reference is NaN everywhere except the plain flux sum assert np.all(np.isfinite(result[:, 0, 0])) assert np.all(np.isnan(result[:, 1:, :])) assert np.all(np.isnan(result[:, :, 1:])) def test_moment_err(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) error = np.ascontiguousarray(scene['error'], dtype=np.float64) raw = _reference_raw(cat) xcen, ycen = _cutout_centroids(raw) result = batch_moment_err( error, convdata=inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax'], xcen=xcen, ycen=ycen) assert result.shape == (cat.n_labels, 4) assert_allclose(result, _reference_moment_err(cat, xcen, ycen), rtol=1e-12, atol=1e-10, equal_nan=True) def _edge_catalog(): """ Make a small catalog whose sources exercise every pixel-inclusion rule of the moment kernels. """ rng = np.random.default_rng(42) ny = nx = 12 data = np.abs(rng.normal(2.0, 0.5, (ny, nx))) segmdata = np.zeros((ny, nx), dtype=int) segmdata[2:6, 2:6] = 1 segmdata[7:11, 6:11] = 2 convdata = data.copy() convdata[3, 3] = -1.5 # negative convolved value in a segment convdata[4, 4] = np.nan # nonfinite convolved value convdata[8, 8] = np.inf convdata[9, 9] = 0.0 # zero flux weight, excluded from the errors # Nonfinite data with a finite convolved value is included in # the moments and excluded from the errors data[5, 5] = np.nan data[9, 7] = np.nan mask = np.zeros((ny, nx), dtype=bool) mask[2, 2] = True # input-masked pixel in a segment mask[8, 9] = True error = np.full((ny, nx), 0.2) error[::3, ::2] = 0.5 return SourceCatalog(data, SegmentationImage(segmdata), convolved_data=convdata, error=error, mask=mask) def _reference_err_pixels(cat, i): """ Boolean cutout mask of the pixels included in the error sums. """ slc = cat.slices[i] moment_data = _moment_cutout(cat, i) included = np.isfinite(cat._data[slc]) included &= cat._segmentation_image.data[slc] == cat.labels[i] if cat._mask is not None: included &= ~cat._mask[slc] return included & (moment_data != 0) def test_edge_case_inclusion_rules(): cat = _edge_catalog() inp = _driver_inputs(cat) raw = batch_raw_moments( inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax']) reference_raw = _reference_raw(cat) # The excluded pixels must actually change the result, otherwise # this test would not discriminate the inclusion rules for i in range(cat.n_labels): slc = cat.slices[i] in_segment = (cat._segmentation_image.data[slc] == cat.labels[i]) assert (np.count_nonzero(_moment_cutout(cat, i)) < np.count_nonzero(in_segment)) assert_allclose(raw, reference_raw, rtol=1e-12, atol=1e-10, equal_nan=True) xcen, ycen = _cutout_centroids(reference_raw) central = batch_central_moments( inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax'], xcen=xcen, ycen=ycen) assert_allclose(central, _reference_central(cat, xcen, ycen), rtol=1e-12, atol=1e-10, equal_nan=True) error = np.ascontiguousarray(cat._error, dtype=np.float64) err = batch_moment_err( error, convdata=inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax'], xcen=xcen, ycen=ycen) reference_err = _reference_moment_err(cat, xcen, ycen) # The nonfinite data pixel is excluded from the errors but not # from the moments, so the error sums use fewer pixels n_moment = np.array([np.count_nonzero(_moment_cutout(cat, i)) for i in range(cat.n_labels)]) n_err = np.array([np.count_nonzero(_reference_err_pixels(cat, i)) for i in range(cat.n_labels)]) assert np.all(n_err < n_moment) assert_allclose(err, reference_err, rtol=1e-12, atol=1e-10, equal_nan=True) def test_catalog_moments(scene): cat = make_catalog(scene) assert_allclose(cat.moments, _reference_raw(cat), rtol=1e-12, atol=1e-10) raw = cat._array('moments') with np.errstate(invalid='ignore'): xcen = raw[:, 0, 1] / raw[:, 0, 0] ycen = raw[:, 1, 0] / raw[:, 0, 0] assert_allclose(cat.moments_central, _reference_central(cat, xcen, ycen), rtol=1e-12, atol=1e-10, equal_nan=True) # Downstream shape properties still finite where expected assert np.all(np.isfinite(np.atleast_1d( cat.semimajor_axis.value)[~np.isnan( np.atleast_1d(cat.semimajor_axis.value))])) def test_catalog_centroid_err(scene): # _centroid_err_cov = normalized batch_moment_err accumulators. # Rebuild it from the reference accumulation of test_moment_err cat = make_catalog(scene) cov = cat._centroid_err_cov assert cov.shape == (cat.n_labels, 2, 2) raw = _reference_raw(cat) m00 = raw[:, 0, 0] with np.errstate(invalid='ignore'): xcen = raw[:, 0, 1] / m00 ycen = raw[:, 1, 0] / m00 for i in range(cat.n_labels): if not np.isfinite(m00[i]) or m00[i] <= 0: assert np.all(np.isnan(cov[i])) continue slc = cat.slices[i] moment_data = _moment_cutout(cat, i) err_sq = scene['error'][slc].astype(float) ** 2 total_mask = ((cat._segmentation_image.data[slc] != cat.labels[i]) | ~np.isfinite(scene['data'][slc]) | scene['mask'][slc]) err_sq[total_mask | (moment_data == 0)] = 0.0 yy, xx = np.mgrid[0:err_sq.shape[0], 0:err_sq.shape[1]] dx = xx - xcen[i] dy = yy - ycen[i] norm = 1.0 / m00[i] ** 2 var_x = np.sum(err_sq * dx ** 2) * norm var_y = np.sum(err_sq * dy ** 2) * norm cov_xy = np.sum(err_sq * dx * dy) * norm if np.atleast_1d(cat._singular_covariance_mask)[i]: corr = np.sum(err_sq) * (1.0 / 12.0) * norm if var_x * var_y - cov_xy ** 2 < corr ** 2: var_x += corr var_y += corr assert_allclose(cov[i], [[var_x, cov_xy], [cov_xy, var_y]], rtol=1e-12, atol=1e-10) BBOX_NAMES = ['bbox_iymin', 'bbox_iymax', 'bbox_ixmin', 'bbox_ixmax'] def _guard_inputs(scene): """ Build the driver inputs for the input-validation tests, including the centroid and error arrays. """ cat = make_catalog(scene) inp = _driver_inputs(cat) xcen, ycen = _cutout_centroids(_reference_raw(cat)) inp['xcen'] = xcen inp['ycen'] = ycen inp['error'] = np.ascontiguousarray(scene['error'], dtype=np.float64) return inp def _call_central(inp): return batch_central_moments( inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax'], xcen=inp['xcen'], ycen=inp['ycen']) def _call_err(inp): return batch_moment_err( inp['error'], convdata=inp['convdata'], mask=inp['mask'], segm=inp['segm'], labels=inp['labels'], bbox_iymin=inp['bbox_iymin'], bbox_iymax=inp['bbox_iymax'], bbox_ixmin=inp['bbox_ixmin'], bbox_ixmax=inp['bbox_ixmax'], xcen=inp['xcen'], ycen=inp['ycen']) @pytest.mark.parametrize('name', BBOX_NAMES) def test_raw_moments_length_guard(scene, name): inp = _guard_inputs(scene) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_raw(inp) @pytest.mark.parametrize('name', ['mask', 'segm']) def test_raw_moments_shape_guard(scene, name): inp = _guard_inputs(scene) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same shape as convdata' with pytest.raises(ValueError, match=match): _call_raw(inp) @pytest.mark.parametrize('name', [*BBOX_NAMES, 'xcen', 'ycen']) def test_central_moments_length_guard(scene, name): inp = _guard_inputs(scene) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_central(inp) @pytest.mark.parametrize('name', ['mask', 'segm']) def test_central_moments_shape_guard(scene, name): inp = _guard_inputs(scene) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same shape as convdata' with pytest.raises(ValueError, match=match): _call_central(inp) @pytest.mark.parametrize('name', [*BBOX_NAMES, 'xcen', 'ycen']) def test_moment_err_length_guard(scene, name): inp = _guard_inputs(scene) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_err(inp) @pytest.mark.parametrize('name', ['convdata', 'mask', 'segm']) def test_moment_err_shape_guard(scene, name): inp = _guard_inputs(scene) inp[name] = np.ascontiguousarray(inp[name][:-1]) match = f'{name} must have the same shape as error' with pytest.raises(ValueError, match=match): _call_err(inp) def test_thread_safety(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) expected = _call_raw(inp) with ThreadPoolExecutor(max_workers=8) as executor: results = list(executor.map(lambda _: _call_raw(inp), range(16))) for res in results: assert_allclose(res, expected, rtol=0, atol=0, equal_nan=True) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_perimeter.py000066400000000000000000000153331526452531300302550ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch perimeter kernel. """ from concurrent.futures import ThreadPoolExecutor import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation._batch_catalog import batch_perimeter from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) def _reference_perimeter(cat): """ Compute the perimeter of each source segment. This is a verbatim port of the per-source Python implementation of ``perimeter`` that the batch Cython kernel replaces. It is the numerical reference for the kernel. """ 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 cat._cutout_total_masks: if np.all(mask): perimeter.append(np.nan) continue ny, nx = mask.shape padded = np.zeros((ny + 2, nx + 2), dtype=np.int8) padded[1:-1, 1:-1] = ~mask 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 = np.zeros((ny + 2, nx + 2), dtype=np.int8) border[1:-1, 1:-1] = padded[1:-1, 1:-1] & ~eroded 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 @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('with_mask', [True, False]) def test_matches_reference(scene, with_mask): cat = make_catalog(scene, with_mask=with_mask) expected = _reference_perimeter(cat) assert np.all(np.isfinite(expected)) assert_allclose(cat.perimeter, expected, rtol=1e-12) def test_shapes(): # Hand-checkable shapes: a single pixel, a 2x2 square, a 5x5 # square, a diagonal line, an annulus with a masked hole, a fully # masked source, and an edge-touching source data = np.ones((40, 40)) segm_data = np.zeros(data.shape, dtype=int) mask = np.zeros(data.shape, dtype=bool) segm_data[2, 2] = 1 segm_data[5:7, 5:7] = 2 segm_data[10:15, 10:15] = 3 for k in range(5): segm_data[20 + k, 20 + k] = 4 segm_data[25:32, 2:9] = 5 mask[27:30, 4:7] = True segm_data[33:36, 33:36] = 6 mask[33:36, 33:36] = True segm_data[37:40, 0:3] = 7 segm = SegmentationImage(segm_data) cat = SourceCatalog(data, segm, mask=mask) expected = _reference_perimeter(cat) assert_allclose(cat.perimeter, expected, rtol=1e-12, equal_nan=True) # A single pixel has a zero perimeter and the fully masked source # has none assert cat.perimeter[0] == 0 * u.pix assert np.isnan(cat.perimeter[5]) # The annulus perimeter includes the inner hole square = SourceCatalog(data, SegmentationImage( np.where(segm_data == 5, 5, 0))).perimeter[0] assert cat.perimeter[4] > square def test_scalar_and_sliced_catalog(scene): parent = make_catalog(scene) expected = _reference_perimeter(parent) child = parent[[1, 4, 6]] assert_array_equal(child.perimeter.value, parent.perimeter.value[[1, 4, 6]]) assert_allclose(child.perimeter, expected[[1, 4, 6]], rtol=1e-12) scalar = make_catalog(scene)[3] assert scalar.isscalar assert_allclose(scalar.perimeter, expected[3], rtol=1e-12) assert scalar.perimeter == parent.perimeter[3] def test_batch_bboxes_cached_and_sliced(scene): """ Test that the cached bounding-box array is sliced along the source axis, including for scalar catalogs, and matches a fresh catalog. """ cat = make_catalog(scene) bboxes = cat._batch_bboxes assert bboxes.shape == (cat.n_labels, 4) assert bboxes.dtype == np.intp iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() for col in (iymin, iymax, ixmin, ixmax): assert col.flags.c_contiguous assert_array_equal(np.column_stack((iymin, iymax, ixmin, ixmax)), bboxes) slices = cat.slices assert_array_equal(iymin, [slc[0].start for slc in slices]) assert_array_equal(ixmax, [slc[1].stop for slc in slices]) child = cat[[1, 4, 6]] assert_array_equal(child._batch_bboxes, bboxes[[1, 4, 6]]) scalar = cat[3] assert scalar._batch_bboxes.shape == (1, 4) assert_array_equal(scalar._batch_bboxes[0], bboxes[3]) fresh = make_catalog(scene)[3] assert_array_equal(fresh._batch_bboxes, scalar._batch_bboxes) def _driver_inputs(cat): arrays = cat._get_batch_arrays() iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() return {'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(cat.labels, dtype=np.intp), 'bbox_iymin': iymin, 'bbox_iymax': iymax, 'bbox_ixmin': ixmin, 'bbox_ixmax': ixmax} def _call_driver(inp): return batch_perimeter(inp.pop('mask'), **inp) def test_histogram(scene): # Every bounding-box pixel is counted in the histogram (values of # 34 and above are dropped) cat = make_catalog(scene) hist = _call_driver(_driver_inputs(cat)) assert hist.shape == (cat.n_labels, 34) iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() assert np.all(hist.sum(axis=1) <= (iymax - iymin) * (ixmax - ixmin)) @pytest.mark.parametrize('name', ['bbox_iymin', 'bbox_iymax', 'bbox_ixmin', 'bbox_ixmax']) def test_length_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1] match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_shape_guard(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) inp['segm'] = inp['segm'][:-1, :] match = 'segm must have the same shape as mask' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_thread_safety(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) expected = _call_driver(dict(inp)) def run(_): return _call_driver(dict(inp)) with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(run, range(8))) for result in results: assert_array_equal(result, expected) astropy-photutils-24be529/photutils/segmentation/tests/test_batch_segment_gather.py000066400000000000000000000315761526452531300312640ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the batch segment pixel gather. """ from concurrent.futures import ThreadPoolExecutor import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal from photutils.morphology import gini as gini_func from photutils.segmentation import SegmentationImage, SourceCatalog from photutils.segmentation._batch_catalog import batch_segment_gather from photutils.segmentation.catalog import _SegmentValues from photutils.segmentation.tests._batch_scene import (make_batch_scene, make_catalog) def _reference_values(masked_cutouts): """ Get a list of 1D arrays of the unmasked values of each masked cutout. This is a verbatim port of the previous ``_get_values`` method, applied to the public masked cutouts, that the batch segment gather replaces. It is the reference for the gather. """ values = [] for arr in masked_cutouts: compressed = arr.compressed() if len(compressed) == 0: compressed = np.array([np.nan]) values.append(compressed) return values def _assert_values_equal(values, expected): assert len(values) == len(expected) assert values.offsets[-1] == values.packed.size assert_array_equal(values.sizes, np.maximum(values.counts, 1)) for got, want in zip(values.values, expected, strict=True): assert got.dtype == np.float64 assert_array_equal(got, want) @pytest.fixture(scope='module') def scene(): return make_batch_scene() @pytest.mark.parametrize('with_error', [True, False]) @pytest.mark.parametrize('with_mask', [True, False]) @pytest.mark.parametrize('with_background', [True, False]) def test_matches_reference(scene, with_error, with_mask, with_background): background = None if with_background: rng = np.random.default_rng(1) background = rng.normal(0.5, 0.1, scene['data'].shape) cat = SourceCatalog(scene['data'], scene['segm'], error=scene['error'] if with_error else None, mask=scene['mask'] if with_mask else None, background=background) _assert_values_equal(cat._data_values, _reference_values(cat.data_cutout_masked)) if with_error: _assert_values_equal(cat._error_values, _reference_values(cat.error_cutout_masked)) else: assert cat._error_values is None if with_background: _assert_values_equal( cat._background_values, _reference_values(cat.background_cutout_masked)) else: assert cat._background_values is None assert_array_equal(cat._all_masked, [np.all(mask) for mask in cat._cutout_total_masks]) def test_all_masked_source(scene): # A completely masked source gathers a single NaN and is flagged as # all masked. A source with a single unmasked pixel is not mask = scene['mask'].copy() segm = scene['segm'] slc = segm.slices[0] mask[slc] |= segm.data[slc] == segm.labels[0] slc = segm.slices[1] single = segm.data[slc] == segm.labels[1] single[np.flatnonzero(single.ravel())[0] // single.shape[1], np.flatnonzero(single.ravel())[0] % single.shape[1]] = False mask[slc] |= single cat = SourceCatalog(scene['data'], segm, mask=mask, error=scene['error']) expected = _reference_values(cat.data_cutout_masked) assert expected[0].size == 1 assert np.isnan(expected[0][0]) assert expected[1].size == 1 assert np.isfinite(expected[1][0]) _assert_values_equal(cat._data_values, expected) _assert_values_equal(cat._error_values, _reference_values(cat.error_cutout_masked)) assert cat._all_masked[0] assert not cat._all_masked[1] assert_array_equal(cat._all_masked, [np.all(mask) for mask in cat._cutout_total_masks]) assert np.isnan(cat.segment_flux[0]) assert np.isnan(cat.area.value[0]) assert cat.area.value[1] == 1 def test_properties(scene): # The pixel-statistics properties computed from the gathered values # match the same reductions of the reference values rng = np.random.default_rng(2) background = rng.normal(0.5, 0.1, scene['data'].shape) cat = SourceCatalog(scene['data'], scene['segm'], error=scene['error'], mask=scene['mask'], background=background) ref = _reference_values(cat.data_cutout_masked) ref_err = _reference_values(cat.error_cutout_masked) ref_bkg = _reference_values(cat.background_cutout_masked) # The catalog sums with ufunc.reduceat (sequential) rather than # np.sum (pairwise), so the sums agree to rounding only assert_allclose(cat.segment_flux, np.array([np.sum(arr) for arr in ref]), rtol=1e-14) assert_array_equal(cat.min_value, np.array([np.min(arr) for arr in ref])) assert_array_equal(cat.max_value, np.array([np.max(arr) for arr in ref])) assert_array_equal(cat.area.value, np.array([arr.size for arr in ref], dtype=float)) assert_allclose(cat.segment_flux_err, np.sqrt([np.sum(arr**2) for arr in ref_err]), rtol=1e-15) assert_allclose(cat.background_sum, np.array([np.sum(arr) for arr in ref_bkg]), rtol=1e-14) assert_allclose(cat.background_mean, np.array([np.mean(arr) for arr in ref_bkg]), rtol=1e-15) def test_scalar_catalog(scene): cat = make_catalog(scene)[2] assert cat.isscalar _assert_values_equal(cat._data_values, _reference_values([cat.data_cutout_masked])) assert cat._all_masked.shape == (1,) assert not cat._all_masked[0] def test_sliced_catalog_equals_parent(scene): parent = make_catalog(scene) _ = parent._data_values _ = parent._all_masked child = parent[[0, 3, 5]] _assert_values_equal(child._data_values, [parent._data_values.values[i] for i in (0, 3, 5)]) assert_array_equal(child._all_masked, parent._all_masked[[0, 3, 5]]) fresh = make_catalog(scene)[[0, 3, 5]] _assert_values_equal(fresh._data_values, child._data_values) def _reference_gini(cat): # The previous per-source gini implementation return np.array([gini_func(arr) for arr in cat._data_values.values]) def _reference_segment_area(cat): # The previous per-source segment_area implementation areas = [] for label, slices in zip(cat.labels, cat.slices, strict=True): areas.append(np.count_nonzero( cat._segmentation_image[slices] == label)) return np.array(areas) @pytest.mark.parametrize('with_mask', [True, False]) def test_gini_matches_reference(scene, with_mask): cat = make_catalog(scene, with_mask=with_mask) expected = _reference_gini(cat) assert np.all(np.isfinite(expected)) assert_allclose(cat.gini, expected, rtol=1e-12) def test_gini_edge_cases(): # A completely masked source (NaN), a single-pixel source (0), a # zero-valued source (0), a source with negative values (absolute # values are used), and a normal source data = np.zeros((30, 30)) yy, xx = np.mgrid[0:30, 0:30] segm_data = np.zeros(data.shape, dtype=int) mask = np.zeros(data.shape, dtype=bool) data[2:5, 2:5] = 3.0 segm_data[2:5, 2:5] = 1 mask[2:5, 2:5] = True data[8, 8] = 5.0 segm_data[8, 8] = 2 segm_data[12:15, 12:15] = 3 data[18:24, 18:24] = -np.exp(-((xx[18:24, 18:24] - 20.5) ** 2 + (yy[18:24, 18:24] - 20.5) ** 2) / 3.0) segm_data[18:24, 18:24] = 4 data[2:9, 20:27] = np.exp(-((xx[2:9, 20:27] - 23) ** 2 + (yy[2:9, 20:27] - 5) ** 2) / 4.0) segm_data[2:9, 20:27] = 5 cat = SourceCatalog(data, SegmentationImage(segm_data), mask=mask) expected = _reference_gini(cat) assert np.isnan(expected[0]) assert expected[1] == 0.0 assert expected[2] == 0.0 assert 0 < expected[3] < 1 assert_allclose(cat.gini, expected, rtol=1e-12, equal_nan=True) def test_segment_area_matches_reference(scene): cat = make_catalog(scene) expected = _reference_segment_area(cat) assert_array_equal(cat.segment_area.value, expected) assert cat.segment_area.unit == u.pix**2 # Unaffected by the mask, unlike area assert np.all(cat.segment_area.value >= cat.area.value) child = cat[[2, 0, 5]] assert_array_equal(child.segment_area.value, expected[[2, 0, 5]]) scalar = make_catalog(scene)[4] assert scalar.segment_area.value == expected[4] def _sample_values(): """ A container of four sources with 3, 0 (masked placeholder), 1, and 2 values. """ packed = np.array([1.0, -2.0, 3.0, np.nan, 5.0, 6.0, 7.0]) offsets = np.array([0, 3, 4, 5, 7], dtype=np.intp) counts = np.array([3, 0, 1, 2], dtype=np.intp) return _SegmentValues(packed, offsets, counts) class TestSegmentValues: """ Tests for the packed per-source value container. """ def test_attributes(self): values = _sample_values() assert len(values) == 4 assert_array_equal(values.sizes, [3, 1, 1, 2]) arrays = values.values assert len(arrays) == 4 assert_array_equal(arrays[0], [1.0, -2.0, 3.0]) assert np.isnan(arrays[1][0]) assert_array_equal(arrays[3], [6.0, 7.0]) # Iteration yields the per-source arrays for got, want in zip(values, arrays, strict=True): assert_array_equal(got, want) def test_reduce(self): values = _sample_values() assert_allclose(values.reduce(np.add), [2.0, np.nan, 5.0, 13.0]) assert_allclose(values.reduce(np.add, transform=np.square), [14.0, np.nan, 25.0, 85.0]) assert_allclose(values.reduce(np.minimum), [-2.0, np.nan, 5.0, 6.0]) @pytest.mark.parametrize('index', [1, slice(1, 3), [3, 0], np.array([2, 2]), np.array([True, False, False, True])]) def test_getitem(self, index): values = _sample_values() expected = values.values idx = np.atleast_1d(np.arange(4)[index]) result = values[index] assert isinstance(result, _SegmentValues) assert len(result) == len(idx) assert_array_equal(result.counts, values.counts[idx]) assert result.offsets[0] == 0 assert result.offsets[-1] == result.packed.size for got, i in zip(result.values, idx, strict=True): assert_array_equal(got, expected[i]) def test_getitem_scalar_index_keeps_container(self): values = _sample_values() result = values[1] assert len(result) == 1 assert result.counts[0] == 0 assert np.isnan(result.packed[0]) assert_allclose(result.reduce(np.add), [np.nan]) def _driver_inputs(cat): arrays = cat._get_batch_arrays() iymin, iymax, ixmin, ixmax = cat._get_batch_bboxes() return {'values': arrays['data'], 'mask': arrays['mask'], 'segm': arrays['segm'], 'labels': np.ascontiguousarray(cat.labels, dtype=np.intp), 'bbox_iymin': iymin, 'bbox_iymax': iymax, 'bbox_ixmin': ixmin, 'bbox_ixmax': ixmax} def _call_driver(inp): return batch_segment_gather(inp.pop('values'), **inp) def test_driver_outputs(scene): cat = make_catalog(scene) packed, offsets, counts = _call_driver(_driver_inputs(cat)) assert offsets[0] == 0 assert offsets[-1] == packed.size assert_array_equal(np.diff(offsets), np.maximum(counts, 1)) assert_array_equal(counts, [np.count_nonzero(~mask) for mask in cat._cutout_total_masks]) @pytest.mark.parametrize('name', ['bbox_iymin', 'bbox_iymax', 'bbox_ixmin', 'bbox_ixmax']) def test_length_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1] match = f'{name} must have the same length as labels' with pytest.raises(ValueError, match=match): _call_driver(inp) @pytest.mark.parametrize('name', ['mask', 'segm']) def test_shape_guard(scene, name): cat = make_catalog(scene) inp = _driver_inputs(cat) inp[name] = inp[name][:-1, :] match = f'{name} must have the same shape as values' with pytest.raises(ValueError, match=match): _call_driver(inp) def test_thread_safety(scene): cat = make_catalog(scene) inp = _driver_inputs(cat) expected = _call_driver(dict(inp)) def run(_): return _call_driver(dict(inp)) with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(run, range(8))) for result in results: for got, want in zip(result, expected, strict=True): assert_array_equal(got, want) astropy-photutils-24be529/photutils/segmentation/tests/test_catalog.py000066400000000000000000006071001526452531300265310ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the catalog module. """ import threading import tracemalloc from concurrent.futures import ThreadPoolExecutor 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.wcs import WCS from numpy.testing import assert_allclose, assert_equal from photutils.aperture import (BoundingBox, CircularAperture, EllipticalAperture) from photutils.background import Background2D, MedianBackground from photutils.centroids import centroid_quadratic from photutils.datasets import (make_100gaussians_image, make_gwcs, make_noise_image, make_wcs) from photutils.segmentation import catalog as catalog_module from photutils.segmentation.catalog import SourceCatalog from photutils.segmentation.core import SegmentationImage from photutils.segmentation.deblend import deblend_sources from photutils.segmentation.detect import detect_sources from photutils.segmentation.finder import SourceFinder from photutils.segmentation.flags import SEGMENTATION_FLAGS from photutils.segmentation.tests._batch_scene import make_batch_scene from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils._optional_deps import HAS_GWCS, HAS_MATPLOTLIB from photutils.utils._wcs_helpers import compute_pixel_to_sky_jacobians from photutils.utils.cutouts import CutoutImage from photutils.utils.exceptions import PhotutilsDeprecationWarning @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) 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 custom 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]) @pytest.mark.parametrize('cached', ['moments', 'moments_central', 'cutout_centroid', 'centroid', 'covariance_eigvals', 'data_cutout_masked', 'x_centroid', 'semimajor_axis', 'kron_radius', 'centroid_win', 'centroid_quad', 'min_value_index']) def test_scalar_slice_after_cached_dependency(self, cached): """ Test that per-source properties are computed correctly for a scalar catalog obtained by slicing a multisource catalog when only some properties were cached on the parent before slicing. The ``_array`` accessor is used internally by several properties to index along the source axis. A per-source array can be stored either with its leading length-1 axis intact (freshly computed for a scalar catalog) or with that axis removed (a multisource value cached on the parent and then sliced). If ``_array`` does not normalize both cases, moment-derived properties such as ``cutout_centroid`` raise an ``IndexError`` (or return wrong values) when a dependency was cached on the parent but the dependent property was not. This parametrizes over the dependencies that feed other properties through ``_array``, caches exactly one of them on the parent, slices to a scalar, and checks that every property matches the same scalar computed from scratch. """ kwargs = {'error': self.error, 'background': self.background, 'mask': self.mask, 'wcs': self.wcs, 'local_bkg_width': 24} # Source index 1 has no NaN values (see ``test_catalog``), which # avoids NaN-vs-NaN comparison artifacts for properties such as # ``sky_centroid``. idx = 1 # Reference scalar catalog that recomputes every property from # scratch (nothing cached on the parent before slicing). ref = SourceCatalog(self.data, self.segm, **kwargs)[idx] # Cache exactly one dependency on the parent, then slice. parent = SourceCatalog(self.data, self.segm, **kwargs) _ = getattr(parent, cached) scalar = parent[idx] for prop in ref.properties: assert_equal(getattr(scalar, prop), getattr(ref, prop)) def test_iter(self): """ Test that the catalog is iterable. """ labels = [obj.label for obj in self.cat] assert len(labels) == len(self.cat) def test_table(self): """ Test that the catalog can be converted to an Astropy 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_) @pytest.mark.parametrize('columns', ['invalid', 'kron_params', ['_mask', '_slices']]) def test_invalid_column(self, columns): match = 'Invalid column name' with pytest.raises(ValueError, match=match): self.cat.to_table(columns=columns) def test_deprecated_inertia_tensor(self): """ Test that the ``inertia_tensor`` attribute is deprecated and keeps its ``(y, x)`` order and its table column. """ cat = self.cat match = 'deprecated in version 3.1' with pytest.warns(PhotutilsDeprecationWarning, match=match): tensor = cat.inertia_tensor assert 'inertia_tensor' not in cat.properties mu = cat.moments_central assert tensor.unit == u.pix**2 assert_allclose(tensor[:, 0, 0].value, mu[:, 0, 2]) assert_allclose(tensor[:, 1, 1].value, mu[:, 2, 0]) assert_allclose(tensor[:, 0, 1].value, -mu[:, 1, 1]) assert_allclose(tensor[:, 1, 0].value, -mu[:, 1, 1]) with pytest.warns(PhotutilsDeprecationWarning, match=match): tbl = cat.to_table(columns=['inertia_tensor']) assert_allclose(tbl['inertia_tensor'], tensor) # A scalar instance returns a single (2, 2) tensor with pytest.warns(PhotutilsDeprecationWarning, match=match): assert cat[0].inertia_tensor.shape == (2, 2) def test_deprecated_column(self): """ Regression test to ensure that deprecated column names are still accepted by ``to_table``, not rejected by the new column-name validation. """ match = "'xcentroid' attribute was deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match): tbl = self.cat.to_table(columns='xcentroid') assert tbl.colnames == ['x_centroid'] 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 nonzero 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 = 'background must be a 2D array' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, background=5.1) 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 nonfinite (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_detection_catalog_segment_flux_localbkg(self): """ Test that the segment_flux local-background subtraction uses the measurement catalog's unmasked pixel count when a detection catalog with a different mask is input. Regression test for a bug where the local background was multiplied by the detection catalog's "area" property, which counts pixels that are masked (and thus not summed) in the measurement catalog. """ 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) mask = np.zeros(data.shape, dtype=bool) mask[48:52, 48:52] = True det_cat = SourceCatalog(data, segm, local_bkg_width=4) meas_cat = SourceCatalog(data + 3.0, segm, mask=mask, local_bkg_width=4, detection_catalog=det_cat) # The detection-catalog area includes the masked pixels values = meas_cat._data_values.values[0] n_pixels = len(values) assert meas_cat.area.value[0] > n_pixels localbkg = meas_cat._local_background[0] expected = np.sum(values) - n_pixels * localbkg assert_allclose(meas_cat.segment_flux[0], expected) 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_radius_measured_negative(self): """ Test that the measured Kron radius is NaN when the Kron sums are not positive, while the minimum Kron radius is applied. """ cat = SourceCatalog(self.data - 10, self.segm) assert np.all(np.isnan(cat.kron_radius_measured)) assert_equal(cat.kron_radius.value, cat.kron_params[1]) def test_kron_radius_measured_detection_catalog(self): """ Test that the measured Kron radius comes from the detection catalog. """ cat = SourceCatalog(self.data * 2.0 + 1.0, self.segm, detection_catalog=self.cat) assert_equal(cat.kron_radius_measured, self.cat.kron_radius_measured) def test_kron_radius_measured_scalar(self): """ Test the measured Kron radius of a scalar catalog. """ obj = self.cat[1] assert obj.kron_radius_measured.isscalar assert obj.kron_radius_measured == self.cat.kron_radius_measured[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, with the cached values kept # as length-1 arrays (the same shape a scalar catalog stores # when it computes flux_radius itself). obj = cat[1] assert 0.5 in obj._flux_radius_cache assert 0.3 in obj._flux_radius_cache assert obj._flux_radius_cache[0.5].shape == (1,) r_sliced = obj.flux_radius(0.5) assert r_sliced.isscalar assert_allclose(r_sliced.value, r_parent_05[1].value) obj.flux_radius(0.5, name='r_hl') assert obj.r_hl.isscalar assert_allclose(obj.r_hl.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(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 custom 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 cached property cat.add_property('area', segment_snr) with pytest.raises(ValueError, match=match): # Built-in method, which must raise even with overwrite=True cat.add_property('to_table', segment_snr, overwrite=True) 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 custom_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_sliced_catalog_state_isolation(self): """ Test that a sliced catalog does not share mutable state with its parent catalog. Regression test for a bug where ``__getitem__`` copied the ``_custom_properties`` list (and other mutable containers) by reference, so adding a custom property to a slice polluted the parent's ``custom_properties`` list, breaking ``add_property`` and ``to_table`` on the parent for that name. """ cat = SourceCatalog(self.data, self.segm) obj = cat[0] obj.add_property('foo', 1.0) assert 'foo' in obj.custom_properties assert 'foo' not in cat.custom_properties # The parent must still be able to add and use the same name cat.add_property('foo', np.arange(cat.n_labels)) tbl = cat.to_table(columns='foo') assert_equal(tbl['foo'], np.arange(cat.n_labels)) # Parent additions must not leak into existing slices cat.add_property('bar', np.arange(cat.n_labels)) assert 'bar' not in obj.custom_properties # Other mutable containers must also be isolated obj.meta['extra'] = 1 assert 'extra' not in cat.meta obj.default_columns.append('foo') assert 'foo' not in cat.default_columns obj._aperture_mask_kwargs['circ']['method'] = 'center' assert cat._aperture_mask_kwargs['circ']['method'] == 'exact' def test_custom_properties_invalid(self): """ Test custom 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_cached_properties_class_cache(self): """ Test that _cached_properties is cached on the class and shared across instances. """ cat2 = SourceCatalog(self.data, self.segm) result1 = self.cat._cached_properties result2 = cat2._cached_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) def test_sky_bbox_corners(self): """ Test that the sky bounding box vertices are at the outer pixel corners of the bounding box. """ cat = self.cat xmin = cat.bbox_xmin - 0.5 xmax = cat.bbox_xmax + 0.5 ymin = cat.bbox_ymin - 0.5 ymax = cat.bbox_ymax + 0.5 corners = {'ll': (xmin, ymin), 'ul': (xmin, ymax), 'lr': (xmax, ymin), 'ur': (xmax, ymax)} for name, (xpos, ypos) in corners.items(): expected = self.wcs.pixel_to_world(xpos, ypos) skycoord = getattr(cat, f'sky_bbox_{name}') assert_allclose(skycoord.separation(expected).arcsec, 0.0, atol=1.0e-8) def test_select_labels_sliced(self): """ Test that select_label and select_labels on a sliced catalog use the labels of the sliced catalog. """ cat = self.cat[[0, 2, 4]] assert_equal(cat.labels, (1, 3, 5)) assert cat.select_label(3).label == 3 assert_equal(cat.select_labels((5, 1)).labels, (5, 1)) # Labels 2 and 7 are in the segmentation image, but not in the # sliced catalog match = r'label \[2\] is invalid \(not in this catalog\)' with pytest.raises(ValueError, match=match): cat.select_label(2) match = r'labels \[2, 7\] are invalid \(not in this catalog\)' with pytest.raises(ValueError, match=match): cat.select_labels((1, 7, 2)) def test_rename_property_invalid(self): """ Test that renaming a name that is not a user-defined property raises an error and leaves the catalog unchanged. """ cat = SourceCatalog(self.data, self.segm) cat.add_property('prop1', np.arange(len(cat))) for name in ('area', 'not_a_property'): match = f'{name} is not a defined property' with pytest.raises(ValueError, match=match): cat.rename_property(name, 'new_prop') assert cat.custom_properties == ['prop1'] assert 'new_prop' not in cat.__dict__ @pytest.mark.parametrize('cat_kron_params', [(2.5, 1.4, 0.0), (2.5, 1.4)]) def test_make_kron_apertures_min_circ_radius(self, cat_kron_params): """ Test that make_kron_apertures uses the minimum circular radius of the input kron_params. """ cat = SourceCatalog(self.data, self.segm, kron_params=cat_kron_params, aperture_mask_method='none') kron_params = (2.5, 1.4, 20.0) apertures = cat.make_kron_apertures(kron_params=kron_params) circular = np.array([isinstance(aperture, CircularAperture) for aperture in apertures]) assert 0 < np.count_nonzero(circular) < len(cat) for aperture in np.array(apertures, dtype=object)[circular]: assert aperture.r == 20.0 # The apertures are those used by kron_photometry kron_flux, _ = cat.kron_photometry(kron_params) flux = [] for aperture in apertures: aperture_mask = aperture.to_mask(method='exact') weights = aperture_mask.to_image(self.data.shape) flux.append(np.sum(weights * self.data)) assert_allclose(kron_flux, flux) # The Kron apertures of the catalog, which have no minimum # circular radius, are unchanged for aperture in cat.kron_aperture: assert isinstance(aperture, EllipticalAperture) def test_make_kron_apertures_invalid(self): """ Test that make_kron_apertures and plot_kron_apertures validate the input kron_params. """ match = r'kron_params\[0\] must be > 0' with pytest.raises(ValueError, match=match): self.cat.make_kron_apertures(kron_params=(-2.5, 1.4)) with pytest.raises(ValueError, match=match): self.cat.plot_kron_apertures(kron_params=(-2.5, 1.4)) match = 'kron_params must have 2 or 3 elements' with pytest.raises(ValueError, match=match): self.cat.make_kron_apertures(kron_params=(2.5,)) with pytest.raises(ValueError, match=match): self.cat.plot_kron_apertures(kron_params=(2.5,)) def test_flux_radius_detection_catalog(self): """ Test that flux_radius with a detection catalog encloses the fraction of the Kron flux measured in the catalog data. """ # The measurement image has narrower sources and a pedestal yy, xx = np.mgrid[0:101, 0:101] data2 = (Gaussian2D(300.0, 51.0, 52.7, 5.0, 2.0, theta=np.pi / 6.0)(xx, yy) + Gaussian2D(90.0, 20, 80, 3.0, 2.5)(xx, yy) + Gaussian2D(150.0, 75, 18, 5.5, 3.0)(xx, yy) + Gaussian2D(200.0, 90, 90, 2.5, 1.5, theta=-np.pi / 6.0)(xx, yy) + 3.0) detcat = SourceCatalog(self.data, self.segm, local_bkg_width=24) det_radius = detcat.flux_radius(0.5).copy() det_centroid_win = detcat.centroid_win.copy() cat = SourceCatalog(data2, self.segm, local_bkg_width=10, detection_catalog=detcat) fraction = 0.5 radius = cat.flux_radius(fraction) labels = (1, 2, 6, 7) # the sources in the measurement image idx = np.searchsorted(cat.labels, labels) assert np.all(np.isfinite(radius[idx])) assert np.all(np.abs(radius[idx] - det_radius[idx]) > 0.3 * u.pix) # The radius encloses the fraction of the Kron flux of the # catalog, measured in the catalog data with the catalog local # background at the detection catalog centroid for i in idx: flux, _ = cat.circular_photometry(radius[i].value) assert_allclose(flux[i], fraction * cat.kron_flux[i], rtol=1.0e-5) # A catalog sliced before the measurement gives the same radius cat2 = SourceCatalog(data2, self.segm, local_bkg_width=10, detection_catalog=detcat) assert_equal(cat2[idx[1]].flux_radius(fraction), radius[idx[1]]) # The windowed centroid is that of the detection catalog, which # uses the half-light radius of the detection catalog assert_equal(detcat.flux_radius(0.5), det_radius) assert_equal(cat.centroid_win, det_centroid_win) def test_local_background_aperture_detection_catalog(self): """ Test that local_background_aperture uses the local_bkg_width of the catalog instead of that of the detection catalog. """ detcat = SourceCatalog(self.data, self.segm, local_bkg_width=24) cat = SourceCatalog(self.data, self.segm, local_bkg_width=10, detection_catalog=detcat) for aperture, det_aperture in zip(cat.local_background_aperture, detcat.local_background_aperture, strict=True): assert_allclose(aperture.w_out - aperture.w_in, 20.0) assert_allclose(aperture.h_out - aperture.h_in, 20.0) assert aperture.w_in == det_aperture.w_in assert_allclose(det_aperture.w_out - det_aperture.w_in, 48.0) cat = SourceCatalog(self.data, self.segm, local_bkg_width=0, detection_catalog=detcat) assert cat.local_background_aperture[0] is None assert_equal(cat.local_background, 0.0) @pytest.mark.parametrize('dtype', [int, np.uint8, float]) def test_non_boolean_mask(self, dtype): """ Test that a nonboolean mask gives the same catalog as the equivalent boolean mask. """ kwargs = {'error': self.error, 'local_bkg_width': 24} cat1 = SourceCatalog(self.data, self.segm, mask=self.mask, **kwargs) cat2 = SourceCatalog(self.data, self.segm, mask=self.mask.astype(dtype), **kwargs) assert cat1._mask is self.mask assert np.isnan(cat1.segment_flux[0]) props = ('segment_flux', 'segment_flux_err', 'centroid', 'area', 'semimajor_axis', 'min_value', 'max_value', 'local_background', 'kron_flux', 'kron_flux_err', 'centroid_win', 'centroid_quad', 'flags') for prop in props: assert_equal(getattr(cat2, prop), getattr(cat1, prop)) for cutout1, cutout2 in zip(cat1.data_cutout_masked, cat2.data_cutout_masked, strict=True): assert_equal(cutout2.mask, cutout1.mask) 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) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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) class TestSourceCatalogFlags: """ Tests for the SourceCatalog flags property. """ @pytest.fixture(autouse=True) def setup(self): yy, xx = np.mgrid[0:51, 0:51] # Interior source and edge-touching source interior = Gaussian2D(100, 25, 25, 3, 3)(xx, yy) edge = Gaussian2D(100, 2, 40, 3, 3)(xx, yy) self.data = interior + edge self.segm = detect_sources(self.data, 10, 5) assert self.segm.n_labels == 2 def test_flags_zero(self): """ Test that flags are zero for clean interior sources. """ cat = SourceCatalog(self.data, self.segm) interior_idx = np.argmax(cat.bbox_xmin > 0) assert cat.flags[interior_idx] == 0 def test_edge_touch(self): """ Test the edge_touch flag for a source segment touching the image boundary. """ cat = SourceCatalog(self.data, self.segm) edge_bits = cat.flags & SEGMENTATION_FLAGS.EDGE_TOUCH assert np.count_nonzero(edge_bits) == 1 def test_masked_pixels(self): """ Test the masked_pixels flag for input-masked pixels within the source segment. """ mask = np.zeros(self.data.shape, dtype=bool) mask[25, 25] = True # inside the interior source cat = SourceCatalog(self.data, self.segm, mask=mask) idx = np.argmax(cat.bbox_xmin > 0) assert cat.flags[idx] & SEGMENTATION_FLAGS.MASKED_PIXELS other = 1 - idx assert not (cat.flags[other] & SEGMENTATION_FLAGS.MASKED_PIXELS) def test_non_finite_data(self): """ Test the non_finite_data flag. """ data = self.data.copy() data[25, 25] = np.nan cat = SourceCatalog(data, self.segm) idx = np.argmax(cat.bbox_xmin > 0) assert cat.flags[idx] & SEGMENTATION_FLAGS.NON_FINITE_DATA other = 1 - idx assert not (cat.flags[other] & SEGMENTATION_FLAGS.NON_FINITE_DATA) def test_non_finite_error(self): """ Test the non_finite_error flag. """ error = np.ones(self.data.shape) error[25, 25] = np.inf cat = SourceCatalog(self.data, self.segm, error=error) idx = np.argmax(cat.bbox_xmin > 0) assert cat.flags[idx] & SEGMENTATION_FLAGS.NON_FINITE_ERROR other = 1 - idx assert not (cat.flags[other] & SEGMENTATION_FLAGS.NON_FINITE_ERROR) def test_masked_non_finite_pixel(self): """ Test that a pixel that is both input-masked and nonfinite sets the masked_pixels, non_finite_data, and non_finite_error flags together. """ data = self.data.copy() data[25, 25] = np.nan error = np.ones(self.data.shape) error[25, 25] = np.nan mask = np.zeros(self.data.shape, dtype=bool) mask[25, 25] = True cat = SourceCatalog(data, self.segm, error=error, mask=mask) idx = np.argmax(cat.bbox_xmin > 0) expected = (SEGMENTATION_FLAGS.MASKED_PIXELS | SEGMENTATION_FLAGS.NON_FINITE_DATA | SEGMENTATION_FLAGS.NON_FINITE_ERROR) assert cat.flags[idx] & expected == expected other = 1 - idx assert not (cat.flags[other] & expected) # The flags are sliced with the catalog assert cat[idx].flags & expected == expected def test_all_masked(self): """ Test the all_masked flag when every segment pixel is masked. """ mask = np.zeros(self.data.shape, dtype=bool) mask[self.segm.data == self.segm.labels[0]] = True cat = SourceCatalog(self.data, self.segm, mask=mask) assert cat.flags[0] & SEGMENTATION_FLAGS.ALL_MASKED def test_undefined_shape(self): """ Test the undefined_shape flag for a source with nonpositive net flux. """ data = np.zeros((11, 11)) data[3:8, 3:8] = -1.0 segm_data = np.zeros((11, 11), dtype=int) segm_data[3:8, 3:8] = 1 cat = SourceCatalog(data, SegmentationImage(segm_data)) assert cat.flags[0] & SEGMENTATION_FLAGS.UNDEFINED_SHAPE def test_undefined_shape_not_set(self): """ Test that undefined_shape is not set for a normal source. """ cat = SourceCatalog(self.data, self.segm) assert not np.any(cat.flags & SEGMENTATION_FLAGS.UNDEFINED_SHAPE) def test_singular_covariance(self): """ Test the singular_covariance flag for a one-pixel-wide source. """ data = np.zeros((11, 11)) data[5, 2:9] = 10.0 segm_data = np.zeros((11, 11), dtype=int) segm_data[5, 2:9] = 1 cat = SourceCatalog(data, SegmentationImage(segm_data)) assert cat.flags[0] & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE def test_singular_covariance_elongated(self): """ Test the singular_covariance flag for an elongated source that is unresolved along only its minor axis. The covariance determinant of such a source exceeds ``(1/12)**2``, so it is flagged only by the minor-axis variance test. The minor-axis variance is raised to the 1/12 single-pixel floor while the resolved major axis and the orientation are unchanged. """ data = np.zeros((11, 11)) data[4, 2:9] = 0.04 data[5, 2:9] = 1.0 data[6, 2:9] = 0.04 segm_data = np.zeros((11, 11), dtype=int) segm_data[4:7, 2:9] = 1 cat = SourceCatalog(data, SegmentationImage(segm_data)) assert cat.flags[0] & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE # The raw variances are 4 along x (seven pixels of equal flux) # and 0.08 / 1.08 along y, which is below 1/12. assert_allclose(cat.semiminor_axis.value[0], np.sqrt(1 / 12)) assert_allclose(cat.semimajor_axis.value[0], 2.0) assert_allclose(cat.orientation.value[0], 0.0, atol=1e-12) assert_allclose(cat.covariance.value[0], [[4.0, 0.0], [0.0, 1 / 12]], atol=1e-12) @pytest.mark.parametrize(('step', 'angle'), [((1, 1), 45.0), ((3, 1), 18.434948822922), ((3, 2), 33.690067525980)]) def test_singular_covariance_tilted_line(self, step, angle): """ Test a tilted line that is one pixel wide. Its covariance matrix has rank 1, so its determinant is zero apart from rounding, which can make it slightly negative. The shape must be regularized and not set to NaN. """ step_x, step_y = step data = np.zeros((41, 41)) segm_data = np.zeros(data.shape, dtype=int) for i in range(6): data[5 + i * step_y, 5 + i * step_x] = 1.0 + i segm_data[5 + i * step_y, 5 + i * step_x] = 1 cat = SourceCatalog(data, SegmentationImage(segm_data)) assert cat.flags[0] & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE assert_allclose(cat.semiminor_axis.value[0], np.sqrt(1 / 12)) assert cat.semimajor_axis.value[0] > 1.0 assert_allclose(cat.orientation.value[0], angle) def test_singular_covariance_isotropic(self): """ Test a tilted source that is unresolved along both axes. Both variances are raised to the 1/12 floor, so the source is isotropic and its orientation is exactly zero. """ data = np.zeros((11, 11)) data[5, 5] = 97.0 data[6, 6] = 3.0 segm_data = np.zeros(data.shape, dtype=int) segm_data[5, 5] = 1 segm_data[6, 6] = 1 cat = SourceCatalog(data, SegmentationImage(segm_data)) assert cat.flags[0] & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE assert_equal(cat.covariance.value[0], np.eye(2) / 12) assert cat.orientation.value[0] == 0.0 assert cat.eccentricity[0] == 0.0 def test_singular_covariance_not_set(self): """ Test that singular_covariance is not set for a well-resolved source. """ cat = SourceCatalog(self.data, self.segm) assert not np.any(cat.flags & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE) def test_centroid_fallbacks_fully_masked(self): """ Test that a fully-masked source reports both centroid fallbacks (windowed centroid NaN, quadratic centroid NaN). """ mask = np.zeros(self.data.shape, dtype=bool) mask[self.segm.data == self.segm.labels[0]] = True cat = SourceCatalog(self.data, self.segm, mask=mask) assert np.all(np.isnan(cat.centroid_quad[0])) assert cat.centroid_win_fallback[0] assert cat.centroid_quad_fallback[0] def test_centroid_fallbacks_fully_masked_small(self): """ Test that a fully-masked source smaller than the 3x3 quadratic fit box reports both centroid fallbacks. """ data = np.zeros((21, 21)) data[5:7, 5:7] = 10.0 segm_data = np.zeros((21, 21), dtype=int) segm_data[5:7, 5:7] = 1 mask = segm_data.astype(bool) cat = SourceCatalog(data, SegmentationImage(segm_data), mask=mask) assert cat.centroid_win_fallback assert cat.centroid_quad_fallback def test_centroid_fallbacks_not_set(self): """ Test that the centroid fallbacks are not set for clean resolved sources, and that computing the flags does not compute the windowed or quadratic centroids. """ cat = SourceCatalog(self.data, self.segm) cat.flags # noqa: B018 assert '_centroid_win_results' not in cat.__dict__ assert '_centroid_quad_results' not in cat.__dict__ assert not np.any(cat.centroid_win_fallback) assert not np.any(cat.centroid_quad_fallback) def test_centroid_quad_fallback(self): """ Test that centroid_quad_fallback marks the peak pixel and isophotal substitutions, whose values are finite. """ data = np.zeros((30, 30)) segm_data = np.zeros((30, 30), dtype=int) # Peak on the segment edge (the peak pixel is returned) data[2:7, 2:7] = 1.0 + np.arange(5) segm_data[2:7, 2:7] = 1 # A saddle without a maximum (the isophotal centroid is # returned) yy, xx = np.mgrid[-2:3, -2:3] data[12:17, 12:17] = 10.0 + xx**2 - yy**2 + 0.1 * (xx == 0) segm_data[12:17, 12:17] = 2 # A well-sampled peak (the fit is used) y, x = np.mgrid[0:30, 0:30] data += 10 * np.exp(-((x - 23.3)**2 + (y - 23.6)**2) / 4.0) segm_data[20:27, 20:27] = 3 cat = SourceCatalog(data, SegmentationImage(segm_data)) assert_equal(cat.centroid_quad_fallback, [True, True, False]) assert np.all(np.isfinite(cat.centroid_quad)) assert_equal(cat.centroid_quad[0], [6.0, 2.0]) assert_allclose(cat.centroid_quad[2], [23.3, 23.6], atol=0.05) def test_kron_partial_overlap(self): """ Test the kron_partial_overlap flag when the Kron aperture extends beyond the image while the segment does not. """ yy, xx = np.mgrid[0:41, 0:41] data = Gaussian2D(100, 6, 20, 3, 3)(xx, yy) segm = detect_sources(data, 25, 5) # compact segment cat = SourceCatalog(data, segm) assert not (cat.flags[0] & SEGMENTATION_FLAGS.EDGE_TOUCH) assert (cat.flags[0] & SEGMENTATION_FLAGS.KRON_PARTIAL_OVERLAP) def test_kron_partial_overlap_not_set(self): """ Test that kron_partial_overlap is not set for a source whose Kron aperture is completely inside the image. """ cat = SourceCatalog(self.data, self.segm) idx = np.argmax(cat.bbox_xmin > 0) assert not (cat.flags[idx] & SEGMENTATION_FLAGS.KRON_PARTIAL_OVERLAP) @staticmethod def _make_deblended_pair(): """ Make the data and deblended segmentation image for a pair of overlapping sources. """ yy, xx = np.mgrid[0:61, 0:61] data = (Gaussian2D(100, 25, 30, 4, 4)(xx, yy) + Gaussian2D(100, 38, 30, 4, 4)(xx, yy)) segm = detect_sources(data, 10, 5) return data, deblend_sources(data, segm, 5) def test_kron_neighbor_pixels(self): """ Test the kron_neighbor_pixels flag when a neighbor segment falls within the Kron aperture. """ data, segm = self._make_deblended_pair() cat = SourceCatalog(data, segm, aperture_mask_method='correct') assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_NEIGHBOR_PIXELS) def test_kron_uncorrected_pixels(self): """ Test the kron_uncorrected_pixels flag for neighbor pixels within the Kron aperture that could not be replaced by a mirrored value. """ data, segm = self._make_deblended_pair() cat = SourceCatalog(data, segm, aperture_mask_method='correct') assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_UNCORRECTED_PIXELS) # Neighboring sources are neither masked nor corrected cat2 = SourceCatalog(data, segm, aperture_mask_method='none') bits = (SEGMENTATION_FLAGS.KRON_NEIGHBOR_PIXELS | SEGMENTATION_FLAGS.KRON_UNCORRECTED_PIXELS) assert not np.any(cat2.flags & bits) # Neighboring sources are masked, not corrected, so no pixels # are left uncorrected cat3 = SourceCatalog(data, segm, aperture_mask_method='mask') assert np.all(cat3.flags & SEGMENTATION_FLAGS.KRON_NEIGHBOR_PIXELS) assert not np.any(cat3.flags & SEGMENTATION_FLAGS.KRON_UNCORRECTED_PIXELS) def test_kron_neighbor_pixels_not_set(self): """ Test that kron_neighbor_pixels is not set when the Kron aperture contains no neighboring source pixels. """ cat = SourceCatalog(self.data, self.segm) assert not np.any(cat.flags & SEGMENTATION_FLAGS.KRON_NEIGHBOR_PIXELS) def test_kron_masked_pixels(self): """ Test the kron_masked_pixels flag for a masked pixel inside the Kron aperture but outside the segment. """ cat0 = SourceCatalog(self.data, self.segm) interior_idx = np.argmax(cat0.bbox_xmin > 0) # Mask a pixel just outside the segment bbox, inside the # Kron aperture of the interior source x_out = int(cat0.bbox_xmax[interior_idx]) + 1 y_cen = int(cat0.bbox_ymin[interior_idx] + cat0.bbox_ymax[interior_idx]) // 2 mask = np.zeros(self.data.shape, dtype=bool) mask[y_cen, x_out] = True cat = SourceCatalog(self.data, self.segm, mask=mask) assert (cat.flags[interior_idx] & SEGMENTATION_FLAGS.KRON_MASKED_PIXELS) assert not (cat.flags[interior_idx] & SEGMENTATION_FLAGS.MASKED_PIXELS) assert not (cat.flags[interior_idx] & SEGMENTATION_FLAGS.KRON_NON_FINITE_DATA) def test_kron_non_finite_data(self): """ Test the kron_non_finite_data flag for a nonfinite pixel inside the Kron aperture but outside the segment. """ cat0 = SourceCatalog(self.data, self.segm) interior_idx = np.argmax(cat0.bbox_xmin > 0) x_out = int(cat0.bbox_xmax[interior_idx]) + 1 y_cen = int(cat0.bbox_ymin[interior_idx] + cat0.bbox_ymax[interior_idx]) // 2 data = self.data.copy() data[y_cen, x_out] = np.nan cat = SourceCatalog(data, self.segm) flags = cat.flags[interior_idx] assert flags & SEGMENTATION_FLAGS.KRON_NON_FINITE_DATA assert not flags & SEGMENTATION_FLAGS.KRON_MASKED_PIXELS assert not flags & SEGMENTATION_FLAGS.NON_FINITE_DATA def test_local_bkg_too_few_pixels(self): """ Test the local_bkg_too_few_pixels flag, including that it is not set without a local background and that slicing keeps it. """ data = np.zeros((31, 31)) data[4:7, 4:7] = 100.0 data[20:23, 20:23] = 100.0 segm_data = np.zeros(data.shape, dtype=int) segm_data[4:7, 4:7] = 1 segm_data[20:23, 20:23] = 2 segm = SegmentationImage(segm_data) # Mask everything around the first source except its segment # and inner rectangle, leaving no usable annulus pixels mask = np.zeros(data.shape, dtype=bool) mask[:12, :12] = True mask[3:8, 3:8] = False bit = SEGMENTATION_FLAGS.LOCAL_BKG_TOO_FEW_PIXELS cat = SourceCatalog(data, segm, mask=mask) assert not np.any(cat.flags & bit) cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=2) assert_equal((cat.flags & bit) > 0, [True, False]) assert_equal(cat.local_background, [0.0, 0.0]) # Slicing before and after the flags are computed cat = SourceCatalog(data, segm, mask=mask, local_bkg_width=2) assert cat[0].flags & bit assert not cat[1].flags & bit cat.flags # noqa: B018 assert cat[0].flags & bit assert_equal((cat[[1, 0]].flags & bit) > 0, [False, True]) def test_kron_non_finite_error(self): """ Test the kron_non_finite_error flag for a nonfinite error value inside the Kron aperture but outside the segment. """ cat0 = SourceCatalog(self.data, self.segm) interior_idx = np.argmax(cat0.bbox_xmin > 0) x_out = int(cat0.bbox_xmax[interior_idx]) + 1 y_cen = int(cat0.bbox_ymin[interior_idx] + cat0.bbox_ymax[interior_idx]) // 2 error = np.ones(self.data.shape) cat = SourceCatalog(self.data, self.segm, error=error) assert not np.any(cat.flags & SEGMENTATION_FLAGS.KRON_NON_FINITE_ERROR) error[y_cen, x_out] = np.nan cat = SourceCatalog(self.data, self.segm, error=error) flags = cat.flags[interior_idx] assert flags & SEGMENTATION_FLAGS.KRON_NON_FINITE_ERROR assert not flags & SEGMENTATION_FLAGS.NON_FINITE_ERROR assert np.isfinite(cat.kron_flux[interior_idx]) assert np.isnan(cat.kron_flux_err[interior_idx]) def test_kron_undefined(self): """ Test the kron_undefined flag for a fully-masked source (whose Kron aperture is undefined). """ mask = np.zeros(self.data.shape, dtype=bool) mask[self.segm.data == self.segm.labels[0]] = True cat = SourceCatalog(self.data, self.segm, mask=mask) assert cat.kron_aperture[0] is None assert cat.flags[0] & SEGMENTATION_FLAGS.KRON_UNDEFINED @pytest.mark.parametrize('local_bkg_width', [0, 5]) def test_kron_undefined_detection_catalog(self, local_bkg_width): """ Test the kron_undefined flag for a source that is fully masked in the catalog, but not in its detection catalog. """ mask = np.zeros(self.data.shape, dtype=bool) mask[self.segm.data == self.segm.labels[0]] = True error = np.ones(self.data.shape) detcat = SourceCatalog(self.data, self.segm) cat = SourceCatalog(self.data, self.segm, error=error, mask=mask, local_bkg_width=local_bkg_width, detection_catalog=detcat) assert cat.kron_aperture[0] is not None assert np.isnan(cat.kron_flux[0]) assert np.isnan(cat.kron_flux_err[0]) flags = cat.flags[0] assert flags & SEGMENTATION_FLAGS.ALL_MASKED assert flags & SEGMENTATION_FLAGS.KRON_UNDEFINED assert not flags & SEGMENTATION_FLAGS.KRON_NON_FINITE_DATA names = cat.decode_flags()[cat.labels[0]] assert names == ['masked_pixels', 'all_masked', 'kron_undefined'] # The other source is measured assert np.isfinite(cat.kron_flux[1]) assert np.isfinite(cat.kron_flux_err[1]) assert not cat.flags[1] & SEGMENTATION_FLAGS.KRON_UNDEFINED @pytest.mark.parametrize('circular', [False, True]) def test_kron_undefined_oversized(self, circular): """ Test the kron_undefined flag when the Kron photometry is skipped because the aperture is too large. """ if circular: # A huge minimum circular radius makes every Kron aperture # the circle of that radius cat = SourceCatalog(self.data, self.segm, kron_params=(2.5, 1.4, 2000.0)) assert np.all(cat.kron_radius.value == 0) assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_UNDEFINED) else: # A huge semimajor axis with a normal Kron radius makes a # huge Kron ellipse cat = SourceCatalog(self.data, self.segm) huge = np.full(cat.n_labels, 2000.0) << u.pix kron_radius = np.full(cat.n_labels, 1.5) with (patch.object(type(cat), 'semimajor_axis', new_callable=lambda: property( lambda _self: huge)), patch.object(type(cat), 'semiminor_axis', new_callable=lambda: property( lambda _self: huge)), patch.object(type(cat), '_measured_kron_radius', new_callable=lambda: property( lambda _self: kron_radius))): assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_UNDEFINED) def test_kron_no_overlap(self): """ Test the kron_no_overlap flag when the Kron aperture is completely outside the data array. """ cat = SourceCatalog(self.data, self.segm) _ = cat.kron_radius # measure the Kron radii before moving cat.__dict__['x_centroid'] = np.full(cat.n_labels, -1000.0) cat.__dict__['y_centroid'] = np.full(cat.n_labels, -1000.0) assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_NO_OVERLAP) def test_kron_no_overlap_zero_weights(self): """ Test the kron_no_overlap flag when the Kron aperture bounding box overlaps the data, but no pixel within the data has a nonzero aperture weight. """ # A minimum circular radius of 11 makes every Kron aperture a # circle of that radius, then centered at (-11.5, -11.5) its # bounding box reaches the first data column, but no pixel # center lies within the circle cat = SourceCatalog(self.data, self.segm, kron_params=(2.5, 1.4, 11.0)) assert np.all(cat.kron_radius.value == 0) cat.__dict__['x_centroid'] = np.full(cat.n_labels, -11.5) cat.__dict__['y_centroid'] = np.full(cat.n_labels, -11.5) assert np.all(np.isnan(cat.kron_flux)) assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_NO_OVERLAP) assert not np.any(cat.flags & SEGMENTATION_FLAGS.KRON_PARTIAL_OVERLAP) def test_kron_minimum_radius(self): """ Test the kron_minimum_radius flag when the measured Kron radius is clipped to the kron_params minimum. """ # A large minimum unscaled Kron radius forces clipping for # every source cat = SourceCatalog(self.data, self.segm, kron_params=(2.5, 5.9, 0.0)) assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_MINIMUM_RADIUS) # The default minimum is not triggered by these resolved # sources. The measured unscaled Kron radii are approximately # [1.4318, 1.4058] against the default minimum of 1.4, a margin # of about 0.006 for the smaller value. A future failure here # likely indicates a fixture-tolerance drift rather than a flag # regression. cat2 = SourceCatalog(self.data, self.segm) assert not np.any(cat2.flags & SEGMENTATION_FLAGS.KRON_MINIMUM_RADIUS) def test_kron_minimum_circular_radius(self): """ Test the kron_minimum_radius flag when the minimum circular aperture is used (kron_radius set to zero). """ # A large minimum circular radius forces the circular fallback # for every source cat = SourceCatalog(self.data, self.segm, kron_params=(2.5, 1.4, 50.0)) assert_equal(cat.kron_radius.value, np.zeros(2)) assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_MINIMUM_RADIUS) def test_kron_minimum_radius_detection_catalog(self): """ Test that the kron_minimum_radius flag follows the kron_params of the detection catalog, which measures the Kron radii. A detection catalog overrides the input ``kron_params``, so the minimum radius of the detection catalog is the one that is applied. """ # The detection catalog does not apply its minimum, even though # the measured radii are below the requested minimum. detcat = SourceCatalog(self.data, self.segm, kron_params=(2.5, 1.4)) cat = SourceCatalog(self.data, self.segm, kron_params=(2.5, 5.9), detection_catalog=detcat) assert cat.kron_params == detcat.kron_params assert not np.any(cat.flags & SEGMENTATION_FLAGS.KRON_MINIMUM_RADIUS) # The detection catalog applies its minimum to every source detcat2 = SourceCatalog(self.data, self.segm, kron_params=(2.5, 5.9)) cat2 = SourceCatalog(self.data, self.segm, kron_params=(2.5, 1.4), detection_catalog=detcat2) assert cat2.kron_params == detcat2.kron_params assert np.all(cat2.flags & SEGMENTATION_FLAGS.KRON_MINIMUM_RADIUS) def test_kron_minimum_radius_two_element_params(self): """ Test the kron_minimum_radius flag with a two-element kron_params (no minimum circular radius). """ cat = SourceCatalog(self.data, self.segm, kron_params=(2.5, 5.9)) assert np.all(cat.flags & SEGMENTATION_FLAGS.KRON_MINIMUM_RADIUS) def test_provenance_from_deblending(self): """ Test that deblending provenance flags propagate into the catalog flags. """ yy, xx = np.mgrid[0:101, 0:101] data = (Gaussian2D(100, 50, 50, 5, 5)(xx, yy) + Gaussian2D(100, 35, 50, 5, 5)(xx, yy)) segm = detect_sources(data, 10, 5) segm2 = deblend_sources(data, segm, 5) cat = SourceCatalog(data, segm2) assert np.all(cat.flags & SEGMENTATION_FLAGS.DEBLENDED) def test_flags_in_table(self): """ Test that flags is a default table column with int values. """ cat = SourceCatalog(self.data, self.segm) tbl = cat.to_table() assert 'flags' in tbl.colnames assert np.issubdtype(tbl['flags'].dtype, np.integer) def test_flags_slicing(self): """ Test that flags slice consistently with the catalog. """ cat = SourceCatalog(self.data, self.segm) flags = cat.flags assert cat[1:].flags[0] == flags[1] def test_flags_scalar_fresh(self): """ Test that a scalar catalog sliced before the parent catalog's flags are cached computes a flags value that matches the parent catalog's flags. """ cat = SourceCatalog(self.data, self.segm) scalar_cat = cat[0] # slice before flags is accessed/cached assert scalar_cat.flags == cat.flags[0] def test_flags_scalar_cached(self): """ Test that a scalar catalog sliced after the parent catalog's flags are already cached carries the matching flags value. """ cat = SourceCatalog(self.data, self.segm) flags = cat.flags # cache flags on the parent catalog scalar_cat = cat[0] # slice after flags is cached assert scalar_cat.flags == flags[0] def test_flags_docstring_lists_all_flags(self): """ Test that the flags docstring documents every flag name. """ doc = SourceCatalog.flags.func.__doc__ for name in SEGMENTATION_FLAGS.names: assert name in doc def test_decode_flags(self): """ Test that decode_flags returns a dictionary mapping each source label to its active flag names. """ cat = SourceCatalog(self.data, self.segm) decoded = cat.decode_flags() interior_idx = np.argmax(cat.bbox_xmin > 0) interior_label = cat.labels[interior_idx] edge_label = cat.labels[1 - interior_idx] assert list(decoded) == list(cat.labels) assert all(type(key) is int for key in decoded) assert decoded[interior_label] == [] assert decoded[edge_label] == ['edge_touch', 'kron_partial_overlap'] def test_decode_flags_bit_values(self): """ Test that decode_flags returns bit values when requested. """ cat = SourceCatalog(self.data, self.segm) decoded = cat.decode_flags(return_bit_values=True) interior_idx = np.argmax(cat.bbox_xmin > 0) interior_label = cat.labels[interior_idx] edge_label = cat.labels[1 - interior_idx] assert decoded[interior_label] == [] assert decoded[edge_label] == [ SEGMENTATION_FLAGS.EDGE_TOUCH, SEGMENTATION_FLAGS.KRON_PARTIAL_OVERLAP] def test_decode_flags_scalar(self): """ Test that decode_flags on a scalar catalog returns a single-entry dictionary keyed by the source label. """ cat = SourceCatalog(self.data, self.segm) interior_idx = int(np.argmax(cat.bbox_xmin > 0)) scalar_cat = cat[1 - interior_idx] edge_label = int(cat.labels[1 - interior_idx]) assert scalar_cat.decode_flags() == { edge_label: ['edge_touch', 'kron_partial_overlap']} def test_decode_flags_keyword_only(self): """ Test that return_bit_values must be passed as a keyword. """ cat = SourceCatalog(self.data, self.segm) match = 'takes 1 positional argument but 2 were given' with pytest.raises(TypeError, match=match): cat.decode_flags(1) class TestThreadSafety: """ Thread-safety tests for the SourceCatalog class. """ @pytest.fixture(autouse=True) def setup(self): yy, xx = np.mgrid[0:101, 0:101] g1 = Gaussian2D(100, 50, 50, 5, 5) g2 = Gaussian2D(80, 30, 30, 4, 4) g3 = Gaussian2D(60, 70, 30, 3, 3) self.data = g1(xx, yy) + g2(xx, yy) + g3(xx, yy) self.segm = detect_sources(self.data, 10.0, n_pixels=5) @pytest.mark.parametrize('n_threads', [1, 4]) def test_concurrent_cached_property_access(self, n_threads): """ Test concurrent first access of cached properties on a shared catalog, with and without the catalog's own n_threads chunking (whose thread pools then run inside the caller's threads). cached_property does not lock, so concurrent first accesses may run a getter more than once, but every thread must see values identical to the serial computation. """ expected = SourceCatalog(self.data, self.segm) cat = SourceCatalog(self.data, self.segm, n_threads=n_threads) n_threads = 8 barrier = threading.Barrier(n_threads) def worker(_): barrier.wait() return (cat.segment_flux, cat.centroid, cat.kron_flux) with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, range(n_threads))) for segment_flux, centroid, kron_flux in results: assert_equal(segment_flux, expected.segment_flux) assert_equal(centroid, expected.centroid) assert_equal(kron_flux, expected.kron_flux) @pytest.mark.parametrize('n_threads', [1, 4]) def test_concurrent_flux_radius(self, n_threads): """ Test concurrent first flux_radius calls on a shared catalog, with and without the catalog's own n_threads chunking of the preparation and the solve. The _flux_radius_cache dict uses an unlocked check-then-set, so concurrent first calls may compute more than once, but every thread must see values identical to the serial computation. """ expected = SourceCatalog(self.data, self.segm).flux_radius(0.5) cat = SourceCatalog(self.data, self.segm, n_threads=n_threads) n_threads = 8 barrier = threading.Barrier(n_threads) def worker(_): barrier.wait() return cat.flux_radius(0.5) with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, range(n_threads))) for radius in results: assert_allclose(radius.value, expected.value) def test_concurrent_slice_add_property(self): """ Test that threads slicing a shared catalog and adding custom properties to their own slices do not interfere with each other or with the parent catalog. """ cat = SourceCatalog(self.data, self.segm) n_threads = 8 barrier = threading.Barrier(n_threads) def worker(index): barrier.wait() obj = cat[index % cat.n_labels] obj.add_property(f'prop{index}', float(index)) return obj with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, range(n_threads))) assert cat.custom_properties == [] for index, obj in enumerate(results): assert obj.custom_properties == [f'prop{index}'] assert getattr(obj, f'prop{index}') == float(index) def test_concurrent_slice_and_property_access(self): """ Regression test that a shared catalog can be sliced while other threads read its properties and call flux_radius. Slicing collects the cached properties that were already calculated from the instance dict and copies the flux_radius cache. On a free-threaded build this raised "dictionary changed size during iteration" when another thread added a cached property at the same time. """ names = ('segment_flux', 'centroid', 'kron_flux', 'area', 'semimajor_axis', 'orientation', 'fwhm', 'gini', 'perimeter', 'max_value', 'centroid_win', 'centroid_quad', 'covariance', 'moments_central', 'kron_radius', 'flags') expected_cat = SourceCatalog(self.data, self.segm) expected = {name: np.asarray(getattr(expected_cat, name)) for name in names} fractions = np.linspace(0.2, 0.8, len(names)) expected_radius = [expected_cat.flux_radius(fraction).value for fraction in fractions] n_threads = 8 for _ in range(30): cat = SourceCatalog(self.data, self.segm) barrier = threading.Barrier(n_threads) def worker(start, cat=cat, barrier=barrier): barrier.wait() result = [] for i in range(start, start + len(names)): i %= len(names) name = names[i] _ = getattr(cat, name) _ = cat.flux_radius(fractions[i]) obj = cat[1:3] result.append((i, np.asarray(getattr(obj, name)), obj.flux_radius(fractions[i]).value)) return result with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, range(0, 2 * n_threads, 2))) for result in results: for i, value, radius in result: assert_equal(value, expected[names[i]][1:3]) assert_equal(radius, expected_radius[i][1:3]) 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) 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) # The measured Kron radius is not clipped to the minimum measured = cat.kron_radius_measured assert measured.unit == u.pix assert_allclose(measured.value.min(), 0.677399, rtol=1e-6) clipped = measured.value < minrad assert np.count_nonzero(clipped) == 48 assert_equal(cat.kron_radius.value[clipped], minrad) assert_equal(cat.kron_radius[~clipped], measured[~clipped]) 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) # The measured Kron radius does not depend on the minimum values for kron_params in ((2.5, 1.4, 0.0), (2.5, 1.4, 7.0)): cat2 = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert_equal(cat2.kron_radius_measured, cat.kron_radius) 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) 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] # Only the reset source reports the fallback assert_equal(cat.centroid_win_fallback, [False, True]) 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]) # The windowed centroid diverged off the image (a nonellipse # fallback condition), so the fallback is reported assert cat.centroid_win_fallback[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_reduce_negative_data(): """ Test that the packed per-source reductions give 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.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_deprecated(): """ Test that the progress_bar keyword is deprecated and has no effect. """ yy, xx = np.mgrid[0:101, 0:101] data = Gaussian2D(100, 50, 50, 5, 5)(xx, yy) segm = detect_sources(data, 10.0, n_pixels=5) match = "'progress_bar' was deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match): cat = SourceCatalog(data, segm, progress_bar=True) assert cat.progress_bar cat2 = SourceCatalog(data, segm) assert not cat2.progress_bar assert_allclose(cat.centroid_win, cat2.centroid_win) assert_allclose(cat.flux_radius(0.5), cat2.flux_radius(0.5)) 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 # A covariance matrix with eigenvalues 3 and -1 covar = np.array([[[1.0, 2.0], [2.0, 1.0]]]) cat.__dict__['_covariance'] = covar eigvals = cat.covariance_eigvals assert np.all(np.isnan(eigvals.value)) def test_invalid_covariance_flag(single_source_catalog): """ Test that a raw covariance matrix that is not positive semidefinite sets the undefined_shape flag, not the singular_covariance flag, and gives a NaN covariance. """ data, segm, cat = single_source_catalog assert not cat.flags[0] & SEGMENTATION_FLAGS.UNDEFINED_SHAPE cat = SourceCatalog(data, segm) # A covariance matrix with eigenvalues 3 and -1 cat.__dict__['_raw_covariance'] = np.array([[[1.0, 2.0], [2.0, 1.0]]]) assert cat.flags[0] & SEGMENTATION_FLAGS.UNDEFINED_SHAPE assert not cat.flags[0] & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE assert np.all(np.isnan(cat.covariance)) def test_partially_finite_covariance_eigvals(centroid_win_data): """ Test that a covariance matrix with any nonfinite element gives NaN eigenvalues, while wholly finite matrices are unaffected. """ data, segm, _convolved_data = centroid_win_data cat = SourceCatalog(data, segm) expected = cat.covariance_eigvals.value covar = cat._covariance.copy() covar[0, 0, 1] = np.nan cat.__dict__['_covariance'] = covar del cat.__dict__['covariance_eigvals'] eigvals = cat.covariance_eigvals.value assert np.all(np.isnan(eigvals[0])) assert_allclose(eigvals[1:], expected[1:]) 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_without_error(single_source_catalog): """ Test that _error_values is None when error is None. """ _data, _segm, cat = single_source_catalog assert cat._error_values is None def test_background_values_without_background(single_source_catalog): """ Test that _background_values is None when background is None. """ _data, _segm, cat = single_source_catalog assert cat._background_values is None 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_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) # The measured value is reported as is assert cat2.kron_radius_measured.value == 100.0 # 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_kron_photometry_oom_guard(gauss_101_catalog): """ Test that _calc_kron_photometry returns NaN when the Kron aperture is too large (OOM guard), does not overlap the data, or has no contributing pixels. """ data, segm, cat = gauss_101_catalog # A huge semimajor axis with a normal Kron radius makes a huge # Kron ellipse that exceeds the max_size check huge = np.array([2000.0]) << u.pix kron_radius = np.array([1.5]) with (patch.object(type(cat), 'semimajor_axis', new_callable=lambda: property(lambda _self: huge)), patch.object(type(cat), 'semiminor_axis', new_callable=lambda: property(lambda _self: huge)), patch.object(type(cat), '_measured_kron_radius', new_callable=lambda: property( lambda _self: kron_radius))): assert np.all(np.isnan(cat.kron_flux)) # A huge minimum circular radius makes a huge circular aperture # that exceeds the max_size check cat2 = SourceCatalog(data, segm, kron_params=(2.5, 1.4, 2000.0)) assert np.all(cat2.kron_radius.value == 0) assert np.all(np.isnan(cat2.kron_flux)) # An aperture completely off-image has no overlap cat3 = SourceCatalog(data, segm) _ = cat3.kron_radius # measure the Kron radius before moving cat3.__dict__['x_centroid'] = np.array([-1000.0]) cat3.__dict__['y_centroid'] = np.array([-1000.0]) assert np.all(np.isnan(cat3.kron_flux)) # An aperture whose pixels are all masked has no contributing # pixels, giving NaN flux and flux error error = np.ones_like(data) mask = np.zeros(data.shape, dtype=bool) mask[:40, :40] = True cat4 = SourceCatalog(data, segm, error=error, mask=mask) _ = cat4.kron_radius # measure the Kron radius before moving cat4.__dict__['x_centroid'] = np.array([10.0]) cat4.__dict__['y_centroid'] = np.array([10.0]) 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 assert_allclose_per_source(actual, desired, rtol): """ Assert that two per-source arrays agree to within ``rtol``. The tolerance is relative to each value and also to the largest finite magnitude of the same source (the first axis), because elements that are analytically zero (e.g., the first-order central moments) are rounding residuals of much larger terms of that source. """ actual = np.asarray(actual, dtype=float) desired = np.asarray(desired, dtype=float) finite = np.where(np.isfinite(desired), np.abs(desired), 0.0) axes = tuple(range(1, desired.ndim)) scale = finite.max(axis=axes, keepdims=True) if axes else finite scale = np.where(scale > 0, scale, 1.0) assert_allclose(actual / scale, desired / scale, rtol=rtol, atol=rtol, equal_nan=True) @pytest.fixture def float32_catalog_inputs(): """ Float32 data, error, and convolved data with three sources on a 256x256 grid. Returns ``(data, segm, error, convolved_data)``. """ yy, xx = np.mgrid[0:256, 0:256] data = (Gaussian2D(100, 60, 60, 4, 4)(xx, yy) + Gaussian2D(80, 180, 90, 5, 3)(xx, yy) + Gaussian2D(60, 120, 200, 3, 3)(xx, yy)).astype(np.float32) error = np.full(data.shape, 0.5, dtype=np.float32) kernel = make_2dgaussian_kernel(2.0, size=5) convolved_data = convolve(data, kernel).astype(np.float32) segm = detect_sources(convolved_data, 5.0, n_pixels=5) return data, segm, error, convolved_data def test_release_cache_frees_memory(float32_catalog_inputs): """ Test that release_cache frees the full-image working arrays, including when a sliced catalog is still alive. """ data, segm, error, convolved_data = float32_catalog_inputs # With a float64 error array, the float32 data and convolved data # are copied to float64 so that all of the images have the same # dtype. cat = SourceCatalog(data, segm, error=error.astype(np.float64), convolved_data=convolved_data) tracemalloc.start() try: # The float64 data and convolved data working copies and the # mask plane are built on first use. _ = cat.centroid _ = cat.segment_flux_err sub = cat[0:2] _ = sub.kron_flux used = tracemalloc.get_traced_memory()[0] cat.release_cache() freed = used - tracemalloc.get_traced_memory()[0] finally: tracemalloc.stop() assert freed >= 2 * data.size * 8 assert sub.n_labels == 2 def test_release_cache_results_unchanged(float32_catalog_inputs): """ Test that properties computed before and after release_cache match those of a catalog whose cache was never released. """ data, segm, error, convolved_data = float32_catalog_inputs kwargs = {'error': error, 'convolved_data': convolved_data} cat_ref = SourceCatalog(data, segm, **kwargs) cat = SourceCatalog(data, segm, **kwargs) centroid = cat.centroid assert cat.release_cache() is None assert_equal(cat.centroid, centroid) # New properties rebuild the working arrays on demand assert_equal(cat.centroid, cat_ref.centroid) assert_equal(cat.segment_flux_err, cat_ref.segment_flux_err) assert_equal(cat.kron_flux, cat_ref.kron_flux) # Releasing a catalog that has nothing cached is allowed cat.release_cache() cat.release_cache() assert_equal(cat.kron_flux_err, cat_ref.kron_flux_err) class TestInputDtypes: """ Tests that the compiled code reads float32 and float64 images and int32 and intp segmentation images directly, with the same results. All calculations are performed in float64, so float32 inputs give the same results as the same values input as float64, up to floating-point rounding. The compiler may fuse multiply-add operations differently in the float32 and float64 specializations of the compiled code, which changes some results by one ulp on some platforms (e.g., GCC on aarch64). """ # The relative tolerance for float32 versus float64 inputs (a few # ulps) rtol = 1e-13 @classmethod def assert_properties_close(cls, props, props_ref): """ Assert that the numeric properties agree to within ``rtol`` for floating-point values and exactly otherwise. See `assert_allclose_per_source` for the tolerance. """ assert props.keys() == props_ref.keys() for name, expected in props_ref.items(): value = props[name] if expected.dtype.kind == 'f': try: assert_allclose_per_source(value, expected, cls.rtol) except AssertionError as exc: msg = f'{name} differs' raise AssertionError(msg) from exc else: assert_equal(value, expected, err_msg=name) @staticmethod def make_inputs(size=200): """ Make float64 data, error, background, and convolved data arrays and a segmentation image. """ rng = np.random.default_rng(1) yy, xx = np.mgrid[0:size, 0:size] data = rng.normal(0.0, 1.0, (size, size)) for _ in range(12): xcen, ycen = rng.uniform(15, size - 15, 2) xsig, ysig = rng.uniform(1.5, 4.0, 2) data += Gaussian2D(rng.uniform(50, 200), xcen, ycen, xsig, ysig, rng.uniform(0, np.pi))(xx, yy) data[5, 5] = np.nan error = rng.uniform(0.8, 1.2, data.shape) background = rng.uniform(4.9, 5.1, data.shape) kernel = make_2dgaussian_kernel(2.0, size=5) convolved_data = convolve(data, kernel) segm = detect_sources(convolved_data, 5.0, n_pixels=10) return data, error, background, convolved_data, segm @staticmethod def numeric_properties(cat): """ Return a dict of the per-source numeric property arrays. """ results = {} for name in cat.properties: value = getattr(cat, name) if isinstance(value, u.Quantity): value = value.value if isinstance(value, np.ndarray) and value.dtype.kind in 'fiub': results[name] = value return results @pytest.mark.parametrize('segm_dtype', [np.int16, np.int32, '>i4', np.intp]) @pytest.mark.parametrize('dtype', [np.float32, np.float64]) def test_identical_results(self, dtype, segm_dtype): """ Test that the results do not depend on the input dtypes. The float32 values are exactly representable as float64, so the results must agree with those for the same values input as float64 to within rounding. """ data, error, background, convolved_data, segm = self.make_inputs() data = data.astype(np.float32) error = error.astype(np.float32) background = background.astype(np.float32) convolved_data = convolved_data.astype(np.float32) cat_ref = SourceCatalog( data.astype(float), SegmentationImage(segm.data.astype(np.intp)), error=error.astype(float), background=background.astype(float), convolved_data=convolved_data.astype(float)) cat = SourceCatalog( data.astype(dtype), SegmentationImage(segm.data.astype(segm_dtype)), error=error.astype(dtype), background=background.astype(dtype), convolved_data=convolved_data.astype(dtype)) props_ref = self.numeric_properties(cat_ref) props = self.numeric_properties(cat) assert len(props) > 60 self.assert_properties_close(props, props_ref) assert_allclose(cat.flux_radius(0.5).value, cat_ref.flux_radius(0.5).value, rtol=self.rtol, atol=0, equal_nan=True) def test_mixed_dtypes(self): """ Test float32 data with float64 error and convolved data, noncontiguous float32 inputs, and Quantity inputs. """ data, error, _, convolved_data, segm = self.make_inputs() data32 = data.astype(np.float32) cat_ref = SourceCatalog(data32.astype(float), segm, error=error, convolved_data=convolved_data) props_ref = self.numeric_properties(cat_ref) cat1 = SourceCatalog(data32, segm, error=error, convolved_data=convolved_data) data_fortran = np.asfortranarray(data32) assert not data_fortran.flags.c_contiguous cat2 = SourceCatalog(data_fortran, segm, error=error, convolved_data=convolved_data) cat3 = SourceCatalog(data32 << u.Jy, segm, error=error << u.Jy, convolved_data=convolved_data << u.Jy) # Big-endian float32 (e.g., from a FITS file) and float16 values # are exactly representable in native float32 conv32 = convolved_data.astype(np.float32) cat_ref2 = SourceCatalog(data32.astype(float), segm, error=error.astype(np.float16).astype(float), convolved_data=conv32.astype(float)) cat4 = SourceCatalog(data32.astype('>f4'), segm, error=error.astype(np.float16), convolved_data=conv32.astype('>f4')) self.assert_properties_close(self.numeric_properties(cat4), self.numeric_properties(cat_ref2)) for cat in (cat1, cat2, cat3): self.assert_properties_close(self.numeric_properties(cat), props_ref) def test_float32_inputs_not_copied(self): """ Test that C-contiguous float32 inputs and an int32 segmentation image are used without making full-image float64 copies. """ data, error, _, convolved_data, segm = self.make_inputs(size=512) data = data.astype(np.float32) error = error.astype(np.float32) convolved_data = convolved_data.astype(np.float32) segm = SegmentationImage(segm.data.astype(np.int32)) cat = SourceCatalog(data, segm, error=error, convolved_data=convolved_data) tracemalloc.start() try: start = tracemalloc.get_traced_memory()[0] tbl = cat.to_table() _ = cat.centroid_win _ = cat.centroid_quad _ = cat.flux_radius(0.5) current, peak = tracemalloc.get_traced_memory() finally: tracemalloc.stop() assert len(tbl) == cat.n_labels # A float64 copy of one image needs 8 bytes per pixel. Only the # uint8 mask plane (1 byte per pixel) is kept. The temporary # images used for the flags need about 7 bytes per pixel. assert current - start < 2 * data.size assert peak - start < 8 * data.size def test_big_endian_segmentation_image(self): """ Test that a big-endian int32 segmentation image (e.g., read from a FITS file) is converted to a native int32 copy, not an intp copy. """ data, _, _, _, segm = self.make_inputs(size=512) data = data.astype(np.float32) segm = SegmentationImage(segm.data.astype('>i4')) cat = SourceCatalog(data, segm) tracemalloc.start() try: start = tracemalloc.get_traced_memory()[0] _ = cat.segment_flux current = tracemalloc.get_traced_memory()[0] finally: tracemalloc.stop() # The native int32 copy needs 4 bytes per pixel and the mask # plane 1 byte per pixel. An intp copy would need 8 bytes per # pixel. assert current - start < 6 * data.size 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) 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_scalar(single_source_catalog): """ Test the scalar collapse of method results. A length-1 sequence is collapsed for a scalar catalog, a longer sequence is returned unchanged, and a multisource catalog never collapses. """ _data, _segm, cat = single_source_catalog assert not cat.isscalar obj = cat[0] assert obj.isscalar assert obj._make_scalar([7.0]) == 7.0 assert obj._make_scalar(np.array([3.0]) << u.pix) == 3.0 * u.pix # A sequence that is not length-1 is returned unchanged value = [5.0, 6.0] assert obj._make_scalar(value) is value # A multisource catalog never collapses assert cat._make_scalar([7.0]) == [7.0] def test_flux_radius_optimizer_args_oom_guard(gauss_101_catalog): """ Test that _flux_radius_optimizer_args gives a zero pixel count (and thus a NaN flux radius) 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 gives a zero pixel count (and thus a NaN flux radius) 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)) def test_centroid_win_err(): """ Test that centroid_win_err returns finite 1-sigma position errors when an error array is provided. """ 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 error = np.full(data.shape, 65.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) n_pixels = 10 finder = SourceFinder(n_pixels=n_pixels) threshold = 107.9 segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') errors = cat.centroid_win_err assert errors.shape == (cat.n_labels, 2) # Source 0 converged, so errors should be finite and positive assert np.all(np.isfinite(errors[0])) assert np.all(errors[0] > 0) # Source 1 fell back to the isophotal centroid, so its errors # are the isophotal centroid errors assert_allclose(cat.centroid_win[1], cat.centroid[1]) assert_allclose(errors[1], cat.centroid_err[1]) def test_centroid_win_err_no_error(): """ Test that centroid_win_err returns NaN when no error array is provided. """ g1 = Gaussian2D(1621, 6.29, 10.95, 1.55, 1.29, 0.296706) yy, xx = np.mgrid[0:21, 0:21] data = g1(xx, yy) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) n_pixels = 10 segment_map = detect_sources(convolved_data, 50.0, n_pixels) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, aperture_mask_method='none') errors = cat.centroid_win_err # No error array provided, so errors should be NaN assert np.all(np.isnan(errors)) def test_centroid_win_err_scalar(): """ Test that centroid_win_err works for scalar (single-source) case. """ g1 = Gaussian2D(1621, 10.0, 10.0, 2.0, 2.0) yy, xx = np.mgrid[0:21, 0:21] data = g1(xx, yy) error = np.full(data.shape, 50.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) n_pixels = 10 segment_map = detect_sources(convolved_data, 50.0, n_pixels) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') single = cat[0] # Public properties collapse to a single (x, y) pair for a # scalar catalog errors = single.centroid_win_err assert errors.shape == (2,) assert np.all(np.isfinite(errors)) assert np.all(errors > 0) assert single.x_centroid_win_err == errors[0] assert single.y_centroid_win_err == errors[1] def test_centroid_win_err_sliced(): """ Test that centroid_win_err works on a sliced catalog after centroid_win was computed on the parent catalog (regression test for a side-effect attribute that was not propagated by slicing). """ 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 error = np.full(data.shape, 65.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) finder = SourceFinder(n_pixels=10) segment_map = finder(convolved_data, 107.9) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') # Compute centroid_win on the parent before slicing _ = cat.centroid_win errors = cat.centroid_win_err single = cat[0] assert_allclose(single.centroid_win_err, errors[0]) sub = cat[[1, 0]] assert_allclose(sub.centroid_win_err, errors[[1, 0]]) def test_centroid_win_err_peak_pixel_error(): """ Test centroid_win_err for a source where nonzero error is limited to a single pixel, which makes the propagated error covariance nearly singular. The pixel-size (1/12) variance term is added to both variances, so the errors are finite and nonzero. The source and the errors are symmetric, so the two errors are equal. """ data = np.zeros((21, 21)) data[10, 10] = 10000.0 data[10, 11] = 50.0 data[11, 10] = 50.0 data[10, 9] = 50.0 data[9, 10] = 50.0 # Nonzero error only at center pixel, where the propagated # position error terms vanish error = np.zeros(data.shape) error[10, 10] = 10000.0 segment_data = np.zeros(data.shape, dtype=int) segment_data[9:12, 9:12] = 1 segment_map = SegmentationImage(segment_data) convolved_data = data.copy() cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') errors = np.ravel(cat.centroid_win_err) assert np.all(np.isfinite(errors)) assert np.all(errors > 0) assert_allclose(errors[0], errors[1]) def test_centroid_win_err_cov(): """ Test the windowed pixel error covariance. It is symmetric for every source, near-zero off-diagonal for a circular source with uniform errors, and consistent with centroid_win_err. """ yy, xx = np.mgrid[0:31, 0:31] data = Gaussian2D(500.0, 15.2, 15.6, 2.5, 2.5)(xx, yy) error = np.full(data.shape, 5.0) segment_map = detect_sources(data, 10.0, n_pixels=10) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') cov = cat._centroid_win_err_cov assert cov.shape == (1, 2, 2) assert_allclose(cov[0, 0, 1], cov[0, 1, 0]) # Circular source, uniform errors: negligible x-y covariance assert abs(cov[0, 0, 1]) < 1e-3 * np.sqrt(cov[0, 0, 0] * cov[0, 1, 1]) # Errors are the sqrt of the diagonal assert_allclose(cat.centroid_win_err[0], np.sqrt((cov[0, 0, 0], cov[0, 1, 1]))) def test_centroid_win_err_cov_fallback(): """ Test that fallback sources use the full isophotal covariance. """ 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) yy, xx = np.mgrid[0:21, 0:21] data = (g1 + g2)(xx, yy) data += make_noise_image(data.shape, mean=0, stddev=65.0, seed=123) error = np.full(data.shape, 65.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) finder = SourceFinder(n_pixels=10) segment_map = finder(convolved_data, 107.9) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') # Source 1 falls back to the isophotal centroid (pinned by the # existing test_centroid_win_err test) assert_allclose(cat.centroid_win[1], cat.centroid[1]) assert_allclose(cat._centroid_win_err_cov[1], cat._centroid_err_cov[1]) def test_centroid_err(): """ Test that centroid_err returns finite 1-sigma position errors when an error array is provided. """ 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) error = np.full(data.shape, 65.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) n_pixels = 10 finder = SourceFinder(n_pixels=n_pixels) threshold = 107.9 segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') errors = cat.centroid_err assert errors.shape == (cat.n_labels, 2) # Both sources should have finite, positive errors assert np.all(np.isfinite(errors)) assert np.all(errors > 0) # Errors should be small relative to pixel scale for bright sources assert np.all(errors < 1.0) def test_centroid_err_no_error(): """ Test that centroid_err returns NaN when no error array is provided. """ g1 = Gaussian2D(1621, 10.0, 10.0, 2.0, 2.0) yy, xx = np.mgrid[0:21, 0:21] data = g1(xx, yy) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) n_pixels = 10 segment_map = detect_sources(convolved_data, 50.0, n_pixels) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, aperture_mask_method='none') errors = cat.centroid_err assert np.all(np.isnan(errors)) def test_centroid_err_scalar(): """ Test that centroid_err works for a scalar (single-source) case. """ g1 = Gaussian2D(1621, 10.0, 10.0, 2.0, 2.0) yy, xx = np.mgrid[0:21, 0:21] data = g1(xx, yy) error = np.full(data.shape, 50.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) n_pixels = 10 segment_map = detect_sources(convolved_data, 50.0, n_pixels) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') single = cat[0] # Public properties collapse to a single (x, y) pair for a # scalar catalog errors = single.centroid_err assert errors.shape == (2,) assert np.all(np.isfinite(errors)) assert np.all(errors > 0) assert single.x_centroid_err == errors[0] assert single.y_centroid_err == errors[1] def test_centroid_err_singularity(): """ Test that the singularity correction in centroid_err is applied to a point-like source. The error is nonzero only at the centroid pixel, so the propagated error covariance is zero apart from rounding. Both error variances are raised to the floor ``sum(sigma**2) / (12 F**2)``, which is 1/12 here because the error equals the flux. """ data = np.zeros((11, 11)) data[5, 5] = 10000.0 data[5, 6] = 1e-10 # tiny asymmetry to shift centroid error = np.zeros(data.shape) error[5, 5] = 10000.0 segment_data = np.zeros(data.shape, dtype=int) segment_data[4:7, 4:7] = 1 segment_map = SegmentationImage(segment_data) convolved_data = data.copy() cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') assert cat.flags & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE assert_allclose(np.ravel(cat.centroid_err), np.sqrt(1 / 12)) def test_centroid_err_thin_source(): """ Test that the singularity correction in centroid_err is applied to a thin source that is unresolved along only its minor axis. The shape covariance determinant of this source exceeds ``(1/12)**2`` because of its long major axis. The pixel errors are concentrated in the bright row, so the error covariance matrix is nearly singular along ``y``. Only the error along ``y`` is raised to the floor. The error along the resolved x-axis is unchanged. """ data = np.zeros((15, 25)) data[7, 5:20] = 99.5 data[8, 5:20] = 0.5 error = np.full(data.shape, 1.0e-3) error[7, :] = 1.0 segment_data = np.zeros(data.shape, dtype=int) segment_data[7:9, 5:20] = 1 cat = SourceCatalog(data, SegmentationImage(segment_data), error=error) assert cat.flags & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE flux = np.sum(data) err_sum_norm = np.sum(error[7:9, 5:20]**2) / (12.0 * flux**2) x_err, y_err = np.ravel(cat.centroid_err) assert_allclose(y_err, np.sqrt(err_sum_norm)) # The x centroid is at the center of the 15-pixel rows, so the sum # of (x - x_centroid)**2 over one row is 280 x_var = 280.0 * (1.0 + 1.0e-6) / flux**2 assert_allclose(x_err, np.sqrt(x_var)) def test_centroid_err_resolved_source_singular_error(): """ Test that the singularity correction in centroid_err depends on the error covariance and not on the source shape. The source is well resolved, but the error is nonzero only in the row of the centroid. The propagated error covariance is singular along ``y``, so the error along ``y`` is raised to the floor. The error along ``x`` is unchanged. """ yy, xx = np.mgrid[:21, :21] data = 100.0 * np.exp(-((xx - 10)**2 + (yy - 10)**2) / (2 * 2.0**2)) error = np.zeros(data.shape) error[10, :] = 1.0 segment_data = (data > 1.0).astype(int) cat = SourceCatalog(data, SegmentationImage(segment_data), error=error) assert not cat.flags & SEGMENTATION_FLAGS.SINGULAR_COVARIANCE segment = segment_data.astype(bool) flux = np.sum(data[segment]) err_sum = np.sum(error[segment]**2) x_offsets = xx[10][segment[10]] - 10.0 x_err, y_err = np.ravel(cat.centroid_err) assert_allclose(y_err, np.sqrt(err_sum / (12.0 * flux**2))) assert_allclose(x_err, np.sqrt(np.sum(x_offsets**2) / flux**2)) def test_centroid_err_tilted_line(): """ Test the singularity correction for a tilted line that is one pixel wide, which changes the off-diagonal error covariance term. The propagated error covariance has rank 1 along the line. With a uniform flux ``f`` and error ``sigma`` on nine pixels, its elements are all ``60 sigma**2 / F**2``, where 60 is the sum of the squared pixel offsets and ``F = 9 f``. The error variance across the line is raised from zero to the floor ``9 sigma**2 / (12 F**2)``, and the error variance along the line is unchanged. """ flux_pix = 10.0 sigma = 2.0 data = np.zeros((15, 15)) error = np.zeros(data.shape) segment_data = np.zeros(data.shape, dtype=int) for i in range(9): data[3 + i, 3 + i] = flux_pix error[3 + i, 3 + i] = sigma segment_data[3 + i, 3 + i] = 1 cat = SourceCatalog(data, SegmentationImage(segment_data), error=error) scale = sigma**2 / (9 * flux_pix)**2 floor = 9.0 / 12.0 expected = scale * np.array([[60.0 + floor / 2, 60.0 - floor / 2], [60.0 - floor / 2, 60.0 + floor / 2]]) cov = cat._centroid_err_cov[0] assert_allclose(cov, expected) assert_allclose(np.linalg.eigvalsh(cov), scale * np.array([floor, 120.0])) assert_allclose(np.ravel(cat.centroid_err), np.sqrt(scale * (60.0 + floor / 2))) def test_centroid_err_formula(): """ Test that centroid_err matches the analytic error-propagation formula, Var(x_c) = sum(sigma_i^2 * (x_i - x_c)^2) / F^2, for a nonsingular source (this is also the SourceExtractor and SEP formula). """ yy, xx = np.mgrid[0:25, 0:25] data = Gaussian2D(100.0, 12.2, 12.6, 2.5, 2.5)(xx, yy) error = np.full(data.shape, 3.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[6:20, 6:20] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') mask = segment_data == 1 flux = np.sum(data[mask]) xcen = np.sum(data[mask] * xx[mask]) / flux ycen = np.sum(data[mask] * yy[mask]) / flux var_x = np.sum(error[mask]**2 * (xx[mask] - xcen)**2) / flux**2 var_y = np.sum(error[mask]**2 * (yy[mask] - ycen)**2) / flux**2 errors = cat.centroid_err assert_allclose(cat.centroid[0], (xcen, ycen)) assert_allclose(errors[0], np.sqrt((var_x, var_y))) def test_centroid_err_zero_flux(): """ Test that centroid_err returns NaN when the convolved data total flux is zero within the segment (total_flux <= 0 branch). """ data = np.ones((11, 11)) # convolved_data has zero flux in the segment region convolved_data = np.zeros((11, 11)) error = np.full(data.shape, 10.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[4:7, 4:7] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') errors = cat.centroid_err assert np.all(np.isnan(errors)) def test_centroid_err_columns(): """ Test the x/y centroid error properties and their use as to_table columns. """ 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) error = np.full(data.shape, 65.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) finder = SourceFinder(n_pixels=10) segment_map = finder(convolved_data, 107.9) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') assert_allclose(cat.x_centroid_err, cat.centroid_err[:, 0]) assert_allclose(cat.y_centroid_err, cat.centroid_err[:, 1]) assert_allclose(cat.x_centroid_win_err, cat.centroid_win_err[:, 0]) assert_allclose(cat.y_centroid_win_err, cat.centroid_win_err[:, 1]) columns = ['label', 'x_centroid_err', 'y_centroid_err', 'x_centroid_win_err', 'y_centroid_win_err'] tbl = cat.to_table(columns=columns) assert tbl.colnames == columns assert_allclose(tbl['x_centroid_err'], cat.x_centroid_err) assert_allclose(tbl['y_centroid_win_err'], cat.y_centroid_win_err) def test_centroid_err_cov(): """ Test that the isophotal pixel error covariance matches the analytic formulas, including the off-diagonal term cov = sum(sigma^2 * (x - xc) * (y - yc)) / F^2. """ yy, xx = np.mgrid[0:25, 0:25] data = Gaussian2D(100.0, 12.2, 12.6, 2.5, 1.5, 0.7)(xx, yy) error = np.full(data.shape, 3.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[4:22, 4:22] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') cov = cat._centroid_err_cov assert cov.shape == (1, 2, 2) assert_allclose(cov[0, 0, 1], cov[0, 1, 0]) mask = segment_data == 1 flux = np.sum(data[mask]) xcen = np.sum(data[mask] * xx[mask]) / flux ycen = np.sum(data[mask] * yy[mask]) / flux var_x = np.sum(error[mask]**2 * (xx[mask] - xcen)**2) / flux**2 var_y = np.sum(error[mask]**2 * (yy[mask] - ycen)**2) / flux**2 cov_xy = np.sum(error[mask]**2 * (xx[mask] - xcen) * (yy[mask] - ycen)) / flux**2 assert_allclose(cov[0], [[var_x, cov_xy], [cov_xy, var_y]]) # The public property is the sqrt of the diagonal assert_allclose(cat.centroid_err[0], np.sqrt((var_x, var_y))) def test_centroid_err_cov_no_error(): """ Test that the covariance is NaN when error is not input. """ data = np.ones((11, 11)) data[5, 5] = 10.0 segment_data = np.zeros(data.shape, dtype=int) segment_data[4:7, 4:7] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, aperture_mask_method='none') assert np.all(np.isnan(cat._centroid_err_cov)) def _quad_peak(box): """ Solve for the peak of a quadratic fit to a 3x3 box. This is an independent implementation (via lstsq) used to numerically validate the centroid_quad error propagation. """ yy, xx = np.mgrid[0:3, 0:3] design = np.column_stack([np.ones(9), xx.ravel(), yy.ravel(), (xx * yy).ravel(), (xx**2).ravel(), (yy**2).ravel()]) c = np.linalg.lstsq(design, box.ravel(), rcond=None)[0] det = 4.0 * c[4] * c[5] - c[3]**2 xm = (c[2] * c[3] - 2.0 * c[5] * c[1]) / det ym = (c[1] * c[3] - 2.0 * c[4] * c[2]) / det return np.array([xm, ym]) def test_centroid_quad_err(): """ Test that centroid_quad_err matches a numerical-Jacobian error propagation through the quadratic peak fit. """ yy, xx = np.mgrid[0:25, 0:25] data = Gaussian2D(100.0, 12.2, 12.6, 2.0, 1.5, 0.5)(xx, yy) rng = np.random.default_rng(42) error = rng.uniform(1.0, 3.0, data.shape) segment_data = np.zeros(data.shape, dtype=int) segment_data[6:20, 6:20] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') # The peak pixel is at (12, 13) and the 3x3 fit box lies fully # within the segment, so no masked pixels are involved yidx, xidx = 13, 12 box = data[yidx - 1:yidx + 2, xidx - 1:xidx + 2].copy() box_err = error[yidx - 1:yidx + 2, xidx - 1:xidx + 2].ravel() eps = 1e-6 jacobian = np.zeros((2, 9)) for i in range(9): box_p = box.ravel().copy() box_p[i] += eps box_m = box.ravel().copy() box_m[i] -= eps jacobian[:, i] = (_quad_peak(box_p) - _quad_peak(box_m)) / (2 * eps) var_expected = np.sum(jacobian**2 * box_err**2, axis=1) errors = cat.centroid_quad_err assert errors.shape == (1, 2) assert_allclose(errors[0], np.sqrt(var_expected), rtol=1e-6) assert_allclose(cat.x_centroid_quad_err, errors[:, 0]) assert_allclose(cat.y_centroid_quad_err, errors[:, 1]) def test_centroid_quad_err_no_error(): """ Test that centroid_quad_err is NaN when no error array is input. """ yy, xx = np.mgrid[0:25, 0:25] data = Gaussian2D(100.0, 12.2, 12.6, 2.0, 2.0)(xx, yy) segment_data = np.zeros(data.shape, dtype=int) segment_data[6:20, 6:20] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, aperture_mask_method='none') assert np.all(np.isfinite(cat.centroid_quad)) assert np.all(np.isnan(cat.centroid_quad_err)) def test_centroid_quad_err_fallback(): """ Test that sources where the quadratic centroid fell back to the isophotal centroid have the isophotal centroid errors. """ # A 2x2 segment is too small for the 3x3 quadratic fit, so the # centroid falls back to the isophotal centroid data = np.ones((11, 11)) data[5, 5] = 10.0 error = np.full(data.shape, 1.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[5:7, 5:7] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') assert_allclose(cat.centroid_quad, cat.centroid) assert_allclose(cat.centroid_quad_err, cat.centroid_err) def test_centroid_quad_err_peak_at_edge(): """ Test that sources whose maximum value is at the edge of the cutout return the peak-pixel position with NaN errors. """ # Monotonic gradient: the maximum is at the segment cutout edge yy, xx = np.mgrid[0:11, 0:11] data = (xx + yy).astype(float) error = np.full(data.shape, 1.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[3:8, 3:8] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') assert np.all(np.isfinite(cat.centroid_quad)) assert np.all(np.isnan(cat.centroid_quad_err)) def test_centroid_quad_err_columns(): """ Test the quadratic centroid error properties as to_table columns and the scalar-catalog collapse. """ 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) yy, xx = np.mgrid[0:21, 0:21] data = (g1 + g2)(xx, yy) error = np.full(data.shape, 65.0) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) finder = SourceFinder(n_pixels=10) segment_map = finder(convolved_data, 107.9) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, error=error, aperture_mask_method='none') columns = ['label', 'x_centroid_quad_err', 'y_centroid_quad_err'] tbl = cat.to_table(columns=columns) assert tbl.colnames == columns assert_allclose(tbl['x_centroid_quad_err'], cat.x_centroid_quad_err) single = cat[0] errors = single.centroid_quad_err assert errors.shape == (2,) assert single.x_centroid_quad_err == errors[0] assert single.y_centroid_quad_err == errors[1] def test_centroid_quad_err_cov(): """ Test the quadratic-centroid error covariance against a numerical-Jacobian propagation (cov = sum(Jx * Jy * sigma^2)). """ yy, xx = np.mgrid[0:25, 0:25] data = Gaussian2D(100.0, 12.2, 12.6, 2.0, 1.5, 0.5)(xx, yy) rng = np.random.default_rng(42) error = rng.uniform(1.0, 3.0, data.shape) segment_data = np.zeros(data.shape, dtype=int) segment_data[6:20, 6:20] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') cov = cat._centroid_quad_err_cov assert cov.shape == (1, 2, 2) assert_allclose(cov[0, 0, 1], cov[0, 1, 0]) # Numerical Jacobian over the 3x3 box around the peak pixel # (12, 13), reusing the independent _quad_peak helper defined # earlier in this test module yidx, xidx = 13, 12 box = data[yidx - 1:yidx + 2, xidx - 1:xidx + 2] box_err = error[yidx - 1:yidx + 2, xidx - 1:xidx + 2].ravel() eps = 1e-6 jacobian = np.zeros((2, 9)) for i in range(9): bp = box.ravel().copy() bp[i] += eps bm = box.ravel().copy() bm[i] -= eps jacobian[:, i] = (_quad_peak(bp) - _quad_peak(bm)) / (2 * eps) var_expected = np.sum(jacobian**2 * box_err**2, axis=1) cov_expected = np.sum(jacobian[0] * jacobian[1] * box_err**2) assert_allclose(cov[0], [[var_expected[0], cov_expected], [cov_expected, var_expected[1]]], rtol=1e-6) def test_centroid_quad_err_cov_fallback(): """ Test that quad fallback sources use the isophotal covariance. """ data = np.ones((11, 11)) data[5, 5] = 10.0 error = np.full(data.shape, 1.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[5:7, 5:7] = 1 segment_map = SegmentationImage(segment_data) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, aperture_mask_method='none') assert_allclose(cat.centroid_quad, cat.centroid) assert_allclose(cat._centroid_quad_err_cov, cat._centroid_err_cov) def test_sky_err_from_cov(): """ Test the pixel-to-sky error covariance transport against a hand-rotated covariance for a rotated TAN WCS. """ theta_deg = 30.0 scale = 0.25 # arcsec / pixel wcs = WCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = [10.0, 10.0] wcs.wcs.crval = [150.0, 30.0] theta = np.deg2rad(theta_deg) cd = (scale / 3600.0) * np.array([[-np.cos(theta), np.sin(theta)], [np.sin(theta), np.cos(theta)]]) wcs.wcs.cd = cd data = np.zeros((21, 21)) data[9:12, 9:12] = 10.0 segment_map = SegmentationImage((data > 0).astype(int)) cat = SourceCatalog(data, segment_map, wcs=wcs) pix_cov = np.array([[[0.04, 0.01], [0.01, 0.09]]]) xycen = np.array([[10.0, 10.0]]) sky_err = cat._sky_err_from_cov(pix_cov, xycen) # Reference: compare against the vectorized Jacobian helper # directly (transport identity), and check the isotropic invariant # trace(sky_cov) = scale^2 * trace(pix_cov) jac = compute_pixel_to_sky_jacobians(wcs, xycen[:, 0], xycen[:, 1]) sky_cov = jac[0] @ pix_cov[0] @ jac[0].T assert_allclose(sky_err.to_value('arcsec')[0], np.sqrt(np.diag(sky_cov))) assert_allclose(np.trace(sky_cov), scale**2 * np.trace(pix_cov[0]), rtol=1e-3) # NaN positions give NaN errors xycen_nan = np.array([[np.nan, np.nan]]) sky_err_nan = cat._sky_err_from_cov(pix_cov, xycen_nan) assert np.all(np.isnan(sky_err_nan.value)) def test_sky_centroid_err(): """ Test the sky centroid error properties for all three centroid flavors: shapes, units, axis views, and consistency with the pixel errors for an axis-aligned WCS at dec=0. """ scale = 0.5 # arcsec / pixel wcs = WCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = [15.0, 15.0] wcs.wcs.crval = [150.0, 0.0] wcs.wcs.cd = (scale / 3600.0) * np.array([[-1.0, 0.0], [0.0, 1.0]]) yy, xx = np.mgrid[0:31, 0:31] data = Gaussian2D(500.0, 15.2, 15.6, 2.5, 2.0, 0.3)(xx, yy) error = np.full(data.shape, 5.0) segment_map = detect_sources(data, 10.0, n_pixels=10) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, wcs=wcs, aperture_mask_method='none') for prefix, pix_err in (('sky_centroid', cat.centroid_err), ('sky_centroid_win', cat.centroid_win_err), ('sky_centroid_quad', cat.centroid_quad_err)): sky_err = getattr(cat, f'{prefix}_err') assert sky_err.unit == u.arcsec assert sky_err.shape == (1, 2) # Axis-aligned WCS at dec=0: sky error = scale * pixel error assert_allclose(sky_err.to_value('arcsec')[0], scale * pix_err[0], rtol=1e-3) assert_allclose(getattr(cat, f'{prefix}_ra_err'), sky_err[:, 0]) assert_allclose(getattr(cat, f'{prefix}_dec_err'), sky_err[:, 1]) def test_sky_centroid_err_no_wcs(): """ Test that the sky error properties are None without a wcs. """ data = np.zeros((11, 11)) data[4:7, 4:7] = 10.0 segment_map = SegmentationImage((data > 0).astype(int)) error = np.ones(data.shape) cat = SourceCatalog(data, segment_map, error=error) names = [] for prefix in ('sky_centroid', 'sky_centroid_win', 'sky_centroid_quad'): names.extend([f'{prefix}_err', f'{prefix}_ra_err', f'{prefix}_dec_err']) for name in names: # A length-1 (nonscalar) catalog returns an array of None value = getattr(cat, name) assert np.all(value == np.array(None)) # A scalar catalog collapses to a single None assert getattr(cat[0], name) is None def test_sky_centroid_err_columns(): """ Test sky error columns in to_table and the scalar collapse. """ wcs = WCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = [15.0, 15.0] wcs.wcs.crval = [150.0, 20.0] wcs.wcs.cd = (0.5 / 3600.0) * np.array([[-1.0, 0.0], [0.0, 1.0]]) yy, xx = np.mgrid[0:31, 0:31] data = Gaussian2D(500.0, 15.2, 15.6, 2.5, 2.5)(xx, yy) error = np.full(data.shape, 5.0) segment_map = detect_sources(data, 10.0, n_pixels=10) cat = SourceCatalog(data, segment_map, convolved_data=data, error=error, wcs=wcs, aperture_mask_method='none') columns = ['label', 'sky_centroid_ra_err', 'sky_centroid_dec_err'] tbl = cat.to_table(columns=columns) assert tbl.colnames == columns assert tbl['sky_centroid_ra_err'].unit == u.arcsec single = cat[0] sky_err = single.sky_centroid_err assert sky_err.shape == (2,) assert single.sky_centroid_ra_err == sky_err[0] def _make_rotated_tan_wcs(parity, rotation_deg, *, scale=0.25, crpix=(15.0, 15.0), crval=(150.0, 20.0)): """ Make a TAN WCS whose CD matrix is a rotation of an axis-aligned frame with the given parity. With ``parity=-1`` and no rotation, +x points West and +y points North (the standard "North up, East left" orientation). With ``parity=1`` the x-axis is flipped so that +x points East. """ wcs = WCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = crpix wcs.wcs.crval = crval phi = np.deg2rad(rotation_deg) rotation = np.array([[np.cos(phi), -np.sin(phi)], [np.sin(phi), np.cos(phi)]]) wcs.wcs.cd = (scale / 3600.0) * rotation @ np.diag([parity, 1.0]) return wcs def _elongated_source_catalog(theta_deg, wcs): """ Make a single-source catalog of an elongated Gaussian centered near the WCS reference pixel. """ yy, xx = np.mgrid[0:31, 0:31] theta = np.deg2rad(theta_deg) data = Gaussian2D(500.0, 15.2, 15.6, 3.0, 1.5, theta)(xx, yy) segment_map = detect_sources(data, 10.0, n_pixels=10) return SourceCatalog(data, segment_map, convolved_data=data, wcs=wcs, aperture_mask_method='none') def _wrap_m90_p90(angle_deg): """ Wrap angles in degrees to the range (-90, 90]. """ return -((-angle_deg + 90.0) % 180.0) + 90.0 @pytest.mark.parametrize('theta_deg', [-60.0, 10.0, 45.0, 80.0]) @pytest.mark.parametrize(('parity', 'rotation_deg'), [(-1, 0.0), (1, 0.0), (-1, 30.0), (-1, -75.0), (1, 120.0)]) def test_sky_orientation(theta_deg, parity, rotation_deg): """ Test the sky orientation against the pixel orientation mapped through the CD matrix. Near the reference pixel of a TAN projection the local Jacobian is the CD matrix, so the sky position angle of the pixel major-axis direction is known in closed form. """ wcs = _make_rotated_tan_wcs(parity, rotation_deg) cat = _elongated_source_catalog(theta_deg, wcs) sky_orient = cat.sky_orientation assert sky_orient.unit == u.deg assert sky_orient.shape == (1,) assert np.all(sky_orient > -90 * u.deg) assert np.all(sky_orient <= 90 * u.deg) # The major-axis direction on the sky is the CD matrix (columns x # and y, rows xi=East and eta=North) applied to the pixel direction. # The position angle is measured from North toward East. pix_orient = np.deg2rad(cat.orientation.to_value(u.deg)[0]) pix_dir = np.array([np.cos(pix_orient), np.sin(pix_orient)]) xi, eta = wcs.wcs.cd @ pix_dir expected = _wrap_m90_p90(np.rad2deg(np.arctan2(xi, eta))) assert_allclose(sky_orient.to_value(u.deg)[0], expected, atol=1e-4) # A scalar catalog collapses to a scalar Quantity single = cat[0] assert single.sky_orientation.shape == () assert_allclose(single.sky_orientation, sky_orient[0]) def test_sky_orientation_north_up_east_left(): """ Test the closed-form relation for the standard North-up, East-left orientation, where the position angle is the pixel orientation minus 90 degrees. """ wcs = _make_rotated_tan_wcs(-1, 0.0) cat = _elongated_source_catalog(30.0, wcs) expected = _wrap_m90_p90(cat.orientation.to_value(u.deg) - 90.0) assert_allclose(cat.sky_orientation.to_value(u.deg), expected, atol=1e-4) # A flipped parity (East right) mirrors the position angle wcs_flip = _make_rotated_tan_wcs(1, 0.0) cat_flip = _elongated_source_catalog(30.0, wcs_flip) expected_flip = _wrap_m90_p90(90.0 - cat_flip.orientation.to_value(u.deg)) assert_allclose(cat_flip.sky_orientation.to_value(u.deg), expected_flip, atol=1e-4) @pytest.mark.parametrize('dec_pole', [90.0, -90.0]) @pytest.mark.parametrize('ra_ref', [0.0, 37.0, 270.0]) @pytest.mark.parametrize(('parity', 'rotation_deg'), [(-1, 0.0), (1, 0.0), (-1, 30.0), (1, 120.0)]) def test_sky_orientation_at_pole(dec_pole, ra_ref, parity, rotation_deg): """ Test the sky orientation of a source whose centroid lies on a celestial pole. North is geometrically undefined at the pole. The position angle is measured from the meridian of the right ascension that the WCS reports for the centroid, so it must be finite and consistent with ``sky_centroid``. That right ascension is arbitrary because any roundoff in the centroid changes it, so the expected value is built from the reported one. """ # The zero-indexed pixel (15, 15) is the center of the 31 x 31 # array and maps exactly to the pole. wcs = _make_rotated_tan_wcs(parity, rotation_deg, crpix=(16.0, 16.0), crval=(ra_ref, dec_pole)) yy, xx = np.mgrid[0:31, 0:31] theta = np.deg2rad(25.0) data = Gaussian2D(500.0, 15.0, 15.0, 3.0, 1.5, theta)(xx, yy) segment_map = detect_sources(data, 10.0, n_pixels=10) cat = SourceCatalog(data, segment_map, convolved_data=data, wcs=wcs, aperture_mask_method='none') assert_allclose(cat.centroid, [[15.0, 15.0]], atol=1e-10) sky_centroid = cat.sky_centroid[0] assert_allclose(sky_centroid.dec.deg, dec_pole, atol=1e-10) sky_orient = cat.sky_orientation.to_value(u.deg)[0] assert np.isfinite(sky_orient) assert -90.0 < sky_orient <= 90.0 # The meridians through the pole of a TAN projection centered on the # pole are straight lines in the pixel plane. The pixel direction of # North along the reported meridian is the offset to a point on that # meridian one arcsec from the pole. dec_offset = dec_pole - np.sign(dec_pole) / 3600.0 x_north, y_north = wcs.world_to_pixel_values(sky_centroid.ra.deg, dec_offset) north_angle = np.arctan2(15.0 - y_north, 15.0 - x_north) # East is 90 degrees counterclockwise from North in the pixel plane # for the standard parity and clockwise for the flipped parity. pix_orient = np.deg2rad(cat.orientation.to_value(u.deg)[0]) expected = _wrap_m90_p90(np.rad2deg(-parity * (pix_orient - north_angle))) diff = _wrap_m90_p90(sky_orient - expected) assert_allclose(diff, 0.0, atol=1e-4) def test_sky_orientation_nan(): """ Test that a source with an undefined shape has a NaN sky orientation while the other sources are finite. """ wcs = _make_rotated_tan_wcs(-1, 0.0) yy, xx = np.mgrid[0:31, 0:31] data = (Gaussian2D(500.0, 8.0, 8.0, 3.0, 1.5, 0.5)(xx, yy) + Gaussian2D(500.0, 22.0, 22.0, 3.0, 1.5, 0.5)(xx, yy)) segment_map = detect_sources(data, 10.0, n_pixels=10) assert segment_map.n_labels == 2 mask = segment_map.data == 1 cat = SourceCatalog(data, segment_map, convolved_data=data, wcs=wcs, mask=mask, aperture_mask_method='none') sky_orient = cat.sky_orientation.to_value(u.deg) assert np.isnan(cat.orientation[0]) assert np.isnan(sky_orient[0]) assert np.isfinite(sky_orient[1]) def test_sky_orientation_no_wcs(): """ Test that sky_orientation is None without a wcs. """ cat = _elongated_source_catalog(30.0, None) assert np.all(cat.sky_orientation == np.array(None)) assert cat[0].sky_orientation is None def test_sky_orientation_column(): """ Test the sky_orientation column in to_table. """ wcs = _make_rotated_tan_wcs(-1, 0.0) cat = _elongated_source_catalog(30.0, wcs) columns = ['label', 'orientation', 'sky_orientation'] tbl = cat.to_table(columns=columns) assert tbl.colnames == columns assert tbl['sky_orientation'].unit == u.deg assert_allclose(tbl['sky_orientation'], cat.sky_orientation) class TestPartialPixelErrorWeights: """ Tests that aperture flux errors weight the pixel variances by the squared overlap fractions. The reported flux is sum(w * data) over the aperture pixels, where ``w`` is the pixel overlap fraction. With independent pixel errors, its variance is sum(w**2 * sigma**2), so boundary pixels must contribute their variance weighted by the squared fraction. """ @staticmethod def _expected_error(aperture, error): """ Compute sqrt(sum(w**2 * sigma**2)) from the exact aperture mask. """ apermask = aperture.to_mask(method='exact') slc_large, slc_small = apermask.get_overlap_slices(error.shape) weights = apermask.data[slc_small] return np.sqrt(np.sum(weights**2 * error[slc_large] ** 2)) @pytest.fixture(name='catalog') def fixture_catalog(self): """ A single-source catalog with a nonuniform error array. """ yy, xx = np.mgrid[0:41, 0:41] gmodel = Gaussian2D(100.0, 20.0, 20.0, 2.5, 2.5) data = gmodel(xx, yy) segm_data = (data > 1.0).astype(int) segm = SegmentationImage(segm_data) rng = np.random.default_rng(seed=789) error = rng.uniform(0.5, 1.5, data.shape) return SourceCatalog(data, segm, error=error) def test_circular_photometry_error(self, catalog): radius = 5.0 _, flux_err = catalog.circular_photometry(radius) aperture = catalog.make_circular_apertures(radius)[0] expected = self._expected_error(aperture, catalog._error) assert_allclose(flux_err, expected) def test_kron_flux_err(self, catalog): aperture = catalog.kron_aperture[0] expected = self._expected_error(aperture, catalog._error) assert_allclose(catalog.kron_flux_err, expected) @pytest.fixture def many_source_inputs(): """ Multisource inputs for the n_threads tests. Returns ``(data, segm, error, mask)``. """ data = make_100gaussians_image() - 5.0 # subtract the background segm = detect_sources(data, 9.0, n_pixels=5) error = make_noise_image(data.shape, mean=0, stddev=2.0, seed=0) error = np.abs(error) + 1.0 mask = np.zeros(data.shape, dtype=bool) mask[0:40, 0:60] = True return data, segm, error, mask def _assert_same_value(value, expected): """ Assert that two catalog property values are identical, for arrays, Quantity, SkyCoord, and lists of per-source arrays. """ if isinstance(value, SkyCoord): assert_equal(value.ra.deg, expected.ra.deg) assert_equal(value.dec.deg, expected.dec.deg) elif isinstance(value, u.Quantity): assert value.unit == expected.unit assert_equal(value.value, expected.value) elif isinstance(value, list): assert len(value) == len(expected) for item, ref in zip(value, expected, strict=True): assert_equal(item, ref) else: assert_equal(value, expected) class TestNThreads: """ Tests for the n_threads keyword. """ @pytest.mark.parametrize('n_threads', [2, 8]) @pytest.mark.parametrize('aperture_mask_method', ['correct', 'mask', 'none']) @pytest.mark.parametrize('with_error', [True, False]) def test_identical_results(self, many_source_inputs, n_threads, aperture_mask_method, with_error): """ Test that every property and measurement is identical for any number of threads, for both circular and elliptical Kron apertures, with and without an error array. """ data, segm, error, mask = many_source_inputs kwargs = {'mask': mask, 'local_bkg_width': 10, 'aperture_mask_method': aperture_mask_method, 'kron_params': (2.5, 1.4, 5.0), 'background': np.full(data.shape, 0.1), 'wcs': make_wcs(data.shape)} if with_error: kwargs['error'] = error cat1 = SourceCatalog(data, segm, **kwargs) cat2 = SourceCatalog(data, segm, n_threads=n_threads, **kwargs) assert cat1.n_threads == 1 assert cat2.n_threads == n_threads assert segm.n_labels > n_threads # The minimum circular radius makes some apertures circular n_circ = sum(isinstance(aper, CircularAperture) for aper in cat1.kron_aperture) assert 0 < n_circ < segm.n_labels for name in cat1.properties: _assert_same_value(getattr(cat2, name), getattr(cat1, name)) _assert_same_value(cat2.kron_photometry((2.0, 1.0)), cat1.kron_photometry((2.0, 1.0))) _assert_same_value(cat2.circular_photometry(3.0), cat1.circular_photometry(3.0)) _assert_same_value(cat2.flux_radius(0.5), cat1.flux_radius(0.5)) _assert_same_value(cat2.flux_radius(0.9), cat1.flux_radius(0.9)) def test_n_threads_exceeds_sources(self, many_source_inputs): """ Test that n_threads larger than the number of sources gives the same results. """ data, segm, error, _ = many_source_inputs cat1 = SourceCatalog(data, segm, error=error) cat2 = SourceCatalog(data, segm, error=error, n_threads=10 * segm.n_labels) assert_equal(cat2.kron_flux, cat1.kron_flux) assert_equal(cat2.kron_flux_err, cat1.kron_flux_err) def test_scalar_catalog(self, many_source_inputs): """ Test that a scalar (single-source) catalog with n_threads > 1 gives the same results as the parent catalog. """ data, segm, error, _ = many_source_inputs cat = SourceCatalog(data, segm, error=error, n_threads=4) obj = cat[3] assert obj.n_threads == 4 assert obj.isscalar assert_equal(obj.kron_radius, cat.kron_radius[3]) assert_equal(obj.kron_flux, cat.kron_flux[3]) assert_equal(obj.kron_photometry((2.0, 1.0))[0], cat.kron_photometry((2.0, 1.0))[0][3]) def test_sliced_catalog_new_fraction(self, many_source_inputs): """ Test that a multisource slice of a threaded catalog computes a flux radius for a new fraction identically to the parent. Slicing drops the packed flux-radius inputs, so the slice recomputes them (chunked over its own sources) on the first call for a fraction that the parent had not cached, while the parent's cached fractions are carried over. """ data, segm, error, _ = many_source_inputs cat1 = SourceCatalog(data, segm, error=error) cat2 = SourceCatalog(data, segm, error=error, n_threads=4) cat1.flux_radius(0.5) cat2.flux_radius(0.5) index = slice(2, segm.n_labels - 3) sub1 = cat1[index] sub2 = cat2[index] assert '_flux_radius_optimizer_args' not in sub2.__dict__ assert_equal(sub2.flux_radius(0.5), cat1.flux_radius(0.5)[index]) assert '_flux_radius_optimizer_args' not in sub2.__dict__ # A new fraction recomputes the packed inputs on the slice assert_equal(sub2.flux_radius(0.3), sub1.flux_radius(0.3)) assert_equal(sub2.flux_radius(0.3), cat1.flux_radius(0.3)[index]) assert '_flux_radius_optimizer_args' in sub2.__dict__ def test_indexing_preserves_n_threads(self, many_source_inputs): """ Test that slicing and copying a catalog preserve n_threads. """ data, segm, _, _ = many_source_inputs cat = SourceCatalog(data, segm, n_threads=4) assert cat[1:].n_threads == 4 assert cat[[0, 2]].n_threads == 4 assert cat[0].n_threads == 4 assert cat.copy().n_threads == 4 def test_invalid_n_threads(self, many_source_inputs): """ Test that an error is raised if n_threads is not a positive integer. """ data, segm, _, _ = many_source_inputs match = 'n_threads must be a positive integer' for n_threads in (0, -1, 2.5, True): with pytest.raises(ValueError, match=match): SourceCatalog(data, segm, n_threads=n_threads) @pytest.mark.parametrize('dtype', [np.float32, np.float64]) @pytest.mark.parametrize('local_bkg_width', [0, 8]) def test_read_only_inputs(dtype, local_bkg_width): """ Regression test that read-only (nonwritable) input arrays are accepted, are not modified, and give results identical to writable arrays. """ scene = make_batch_scene() data = scene['data'].astype(dtype) error = scene['error'].astype(dtype) mask = scene['mask'] segm_data = scene['segm'].data.copy() background = np.full(data.shape, 0.5, dtype=dtype) arrays = (data, error, mask, segm_data, background) originals = [arr.copy() for arr in arrays] def compute(): cat = SourceCatalog(data, SegmentationImage(segm_data), error=error, mask=mask, background=background, local_bkg_width=local_bkg_width) result = {} for name in cat.properties: if name.startswith('sky'): continue value = getattr(cat, name) if isinstance(value, (np.ndarray, u.Quantity)): result[name] = np.asarray(value) result['flux_radius'] = np.asarray(cat.flux_radius(0.5)) result['circular'] = np.asarray(cat.circular_photometry(5.0)) result['kron'] = np.asarray(cat.kron_photometry((2.0, 1.2, 0.0))) return result expected = compute() for arr in arrays: arr.setflags(write=False) result = compute() assert len(expected) > 50 for name, value in expected.items(): assert_equal(result[name], value) for arr, original in zip(arrays, originals, strict=True): assert_equal(arr, original) def test_moments_non_finite_data(): """ Test that a pixel with a nonfinite data value and a finite convolved_data value is included in the centroid and shape properties, and that it is excluded if it is also masked. """ yy, xx = np.mgrid[0:25, 0:25] data = Gaussian2D(100.0, 12.2, 12.6, 2.0, 1.5, 0.5)(xx, yy) kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) error = np.full(data.shape, 2.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[6:20, 6:20] = 1 segment_map = SegmentationImage(segment_data) mask = np.zeros(data.shape, dtype=bool) mask[14, 15] = True data_nan = data.copy() data_nan[mask] = np.nan kwargs = {'convolved_data': convolved_data, 'error': error} cat = SourceCatalog(data, segment_map, **kwargs) cat_mask = SourceCatalog(data, segment_map, mask=mask, **kwargs) cat_nan = SourceCatalog(data_nan, segment_map, **kwargs) cat_nan_mask = SourceCatalog(data_nan, segment_map, mask=~np.isfinite(data_nan), **kwargs) # The convolved_data value of the pixel is used moment_props = ('moments', 'moments_central', 'centroid', 'covariance', 'semimajor_axis', 'semiminor_axis', 'orientation', 'fwhm') for prop in moment_props: assert_equal(getattr(cat_nan, prop), getattr(cat, prop)) assert not np.allclose(cat_nan.centroid, cat_mask.centroid, rtol=1.0e-4) # The pixel does not contribute to the centroid errors, and it is # excluded from the properties measured from the data assert np.all(cat_nan.centroid_err < cat.centroid_err) assert_equal(cat_nan.centroid_quad, cat_mask.centroid_quad) # Masking the pixel excludes it from all of the properties for prop in (*moment_props, 'centroid_err', 'centroid_quad'): assert_equal(getattr(cat_nan_mask, prop), getattr(cat_mask, prop)) def _masked_quad_inputs(): """ Make the inputs for the masked-pixel quadratic centroid tests. The source peak pixel is at ``(x, y) = (12, 13)``. The pixel at ``(13, 14)``, a corner of the 3x3 fit box, is outside of the source segment. Returns ``(data, error, segment_map)``. """ yy, xx = np.mgrid[0:25, 0:25] data = Gaussian2D(100.0, 12.2, 12.6, 2.0, 1.5, 0.5)(xx, yy) rng = np.random.default_rng(42) error = rng.uniform(1.0, 3.0, data.shape) segment_data = np.zeros(data.shape, dtype=int) segment_data[6:20, 6:20] = 1 segment_data[14, 13] = 0 return data, error, SegmentationImage(segment_data) @pytest.mark.parametrize('bad_value', ['mask', 'nan']) @pytest.mark.parametrize('bad_pixels', [((12, 11),), ((13, 13),), ((12, 11), (14, 12)), ((12, 11), (14, 11), (14, 12))]) def test_centroid_quad_masked_pixels(bad_pixels, bad_value): """ Test that masked and nonfinite pixels in the 3x3 box are left out of the quadratic centroid fit and of its error propagation, and that an unmasked box pixel outside of the source segment is fit with its data value. """ data, error, segment_map = _masked_quad_inputs() bad = np.zeros(data.shape, dtype=bool) for (yidx, xidx) in bad_pixels: bad[yidx, xidx] = True if bad_value == 'mask': cat = SourceCatalog(data, segment_map, error=error, mask=bad) else: data = data.copy() data[bad] = np.nan cat = SourceCatalog(data, segment_map, error=error) assert not cat.centroid_quad_fallback[0] # The reference is centroid_quadratic on the data cutout with the # bad pixels masked slc = cat.slices[0] assert segment_map.data[14, 13] == 0 cutout = data[slc] cutout_mask = bad[slc] def reference(values): return centroid_quadratic(values, mask=cutout_mask, fit_boxsize=3) assert_allclose(cat.cutout_centroid_quad[0], reference(cutout), rtol=1.0e-10) # The errors are propagated with a numerical Jacobian over the # fitted pixels. The masked pixels have no variance. eps = 1.0e-6 var_expected = np.zeros(2) for yidx in range(12, 15): for xidx in range(11, 14): idx = (yidx - slc[0].start, xidx - slc[1].start) if cutout_mask[idx]: continue cutout_p = cutout.copy() cutout_p[idx] += eps cutout_m = cutout.copy() cutout_m[idx] -= eps jacobian = (reference(cutout_p) - reference(cutout_m)) / (2 * eps) var_expected += jacobian**2 * error[yidx, xidx]**2 assert_allclose(cat.centroid_quad_err[0], np.sqrt(var_expected), rtol=1.0e-6) def test_centroid_quad_outside_segment(): """ Test that the unmasked pixels of the 3x3 box that are outside of the source segment, including a pixel of a neighboring source, are fit with their data values. """ data, error, segment_map = _masked_quad_inputs() segment_data = segment_map.data.copy() segment_data[12, 11] = 2 cat = SourceCatalog(data, SegmentationImage(segment_data), error=error) assert not cat.centroid_quad_fallback[0] slc = cat.slices[0] expected = centroid_quadratic(data[slc], fit_boxsize=3) assert_allclose(cat.cutout_centroid_quad[0], expected, rtol=1.0e-10) zero_filled = np.where(segment_data[slc] == 1, data[slc], 0.0) biased = centroid_quadratic(zero_filled, fit_boxsize=3) assert not np.allclose(expected, biased, rtol=1.0e-3) @pytest.mark.parametrize('bad_pixels', [ # Only 5 pixels are left in the box ((12, 11), (12, 13), (14, 11), (13, 11)), # The 6 remaining pixels are in two rows (a rank-deficient fit) ((12, 11), (12, 12), (12, 13)), # The 6 remaining pixels are in two columns ((12, 11), (13, 11), (14, 11))]) def test_centroid_quad_masked_pixels_fallback(bad_pixels): """ Test that the quadratic centroid falls back to the isophotal centroid if too few pixels are left in the 3x3 box to fit the quadratic polynomial. """ data, error, segment_map = _masked_quad_inputs() mask = np.zeros(data.shape, dtype=bool) for (yidx, xidx) in bad_pixels: mask[yidx, xidx] = True cat = SourceCatalog(data, segment_map, error=error, mask=mask) assert cat.centroid_quad_fallback[0] assert_equal(cat.centroid_quad, cat.centroid) assert_equal(cat.centroid_quad_err, cat.centroid_err) assert np.all(np.isfinite(cat.centroid_quad_err)) def test_centroid_quad_masked_pixels_sliced(): """ Test that a sliced catalog reproduces the quadratic centroids of the parent catalog for sources with and without masked pixels in the 3x3 box. """ yy, xx = np.mgrid[0:25, 0:75] data = (Gaussian2D(100.0, 12.2, 12.6, 2.0, 1.5, 0.5)(xx, yy) + Gaussian2D(80.0, 37.4, 12.3, 1.5, 2.0, 0.2)(xx, yy) + Gaussian2D(90.0, 62.7, 11.8, 1.8, 1.8)(xx, yy)) error = np.full(data.shape, 2.0) segment_data = np.zeros(data.shape, dtype=int) segment_data[6:20, 6:20] = 1 segment_data[6:20, 31:45] = 2 segment_data[6:20, 56:70] = 3 segment_map = SegmentationImage(segment_data) mask = np.zeros(data.shape, dtype=bool) mask[12, 11] = True mask[13, 62] = True mask[11:14, 62] = True cat = SourceCatalog(data, segment_map, error=error, mask=mask) assert_equal(cat.centroid_quad_fallback, [False, False, True]) for i in range(len(cat)): cat2 = SourceCatalog(data, segment_map, error=error, mask=mask) obj = cat2[i] assert_equal(obj.centroid_quad, cat.centroid_quad[i]) assert_equal(obj.centroid_quad_err, cat.centroid_quad_err[i]) assert obj.centroid_quad_fallback == cat.centroid_quad_fallback[i] def test_default_columns_deprecated(): """ Test that the deprecated module-level DEFAULT_COLUMNS constant returns a copy of the 3.0 default to_table() columns, which did not include the flags column. """ data = np.zeros((10, 10)) data[2:5, 2:5] = 1.0 cat = SourceCatalog(data, SegmentationImage((data > 0).astype(int))) match = 'DEFAULT_COLUMNS is deprecated' with pytest.warns(PhotutilsDeprecationWarning, match=match): columns = catalog_module.DEFAULT_COLUMNS assert 'flags' not in columns assert columns == [column for column in cat.default_columns if column != 'flags'] assert cat.to_table(columns=columns).colnames == columns columns.append('foo') with pytest.warns(PhotutilsDeprecationWarning, match=match): assert 'foo' not in catalog_module.DEFAULT_COLUMNS assert 'foo' not in cat.default_columns match = 'has no attribute' with pytest.raises(AttributeError, match=match): _ = catalog_module.NOT_AN_ATTRIBUTE astropy-photutils-24be529/photutils/segmentation/tests/test_core.py000066400000000000000000003375721526452531300260640ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import sys import threading import warnings from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from functools import cached_property, partial from unittest.mock import PropertyMock, patch import numpy as np import pytest from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from scipy.ndimage import find_objects import photutils.segmentation.core as segm_core from photutils.segmentation._label_stats import label_stats from photutils.segmentation.core import Segment, SegmentationImage from photutils.segmentation.deblend import deblend_sources from photutils.segmentation.detect import detect_sources from photutils.utils import circular_footprint from photutils.utils._optional_deps import (HAS_MATPLOTLIB, HAS_RASTERIO, HAS_REGIONS, HAS_SHAPELY) from photutils.utils.exceptions import PhotutilsDeprecationWarning @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_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 nonzero 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_labels_and_areas_seeded_together(self): """ Test that assigning data seeds both labels and areas from a single pass, without computing either on demand. """ segm = SegmentationImage(self.data.copy()) assert 'labels' in segm.__dict__ assert 'areas' in segm.__dict__ assert_equal(segm.labels, [1, 3, 4, 5, 7]) assert_equal(segm.areas, [2, 2, 3, 6, 5]) def test_areas_computed_when_not_seeded(self): """ Test that areas fall back to on-demand computation when they are not seeded, and match the seeded values. """ segm = SegmentationImage(self.data.copy()) expected = segm.areas.copy() segm2 = SegmentationImage._from_data(self.data.copy()) assert 'areas' not in segm2.__dict__ assert_equal(segm2.areas, expected) assert 'areas' in segm2.__dict__ # now cached def test_labels_independent_of_slices_access(self): """ Test that labels do not depend on whether slices have been accessed. """ segm1 = SegmentationImage(self.data.copy()) segm2 = SegmentationImage._from_data(self.data.copy()) _ = segm2.slices assert_equal(segm1.labels, segm2.labels) def test_remove_masked_labels_partial_overlap(self): """ Test remove_masked_labels for both partial_overlap settings. """ mask = np.zeros(self.data.shape, dtype=bool) mask[0, :] = True segm = SegmentationImage(self.data.copy()) segm.remove_masked_labels(mask, partial_overlap=True) # Labels 1 and 4 both touch the masked first row assert_equal(segm.labels, [3, 5, 7]) segm = SegmentationImage(self.data.copy()) segm.remove_masked_labels(mask, partial_overlap=False) # Label 4 extends beyond the mask, so it is kept assert_equal(segm.labels, [3, 4, 5, 7]) def test_data_setter_validation(self): """ Test that the data setter validates reassigned arrays. """ segm = SegmentationImage(self.data.copy()) match = 'Input data must be a numpy array' with pytest.raises(TypeError, match=match): segm.data = [[1, 1], [0, 1]] def test_non_2d_data(self): """ Test that non-2D input is accepted and the derived properties work, but that the bbox attribute requires a 2D array. """ segm = SegmentationImage(np.array([0, 1, 1, 0, 2, 2, 2])) assert_equal(segm.labels, [1, 2]) assert_equal(segm.areas, [2, 3]) assert segm.slices == [(slice(1, 3),), (slice(4, 7),)] match = "The 'bbox' attribute requires a 2D segmentation image" with pytest.raises(ValueError, match=match): _ = segm.bbox def test_zero_size_data(self): """ Test that a zero-size 2D array is still accepted. This guards the negative-value check against raising a numpy reduction error on an empty array. """ segm = SegmentationImage(np.zeros((0, 0), dtype=int)) assert segm.n_labels == 0 assert_equal(segm.labels, []) assert_equal(segm.areas, []) def test_relabel_consecutive_carries_areas(self): """ Test that relabel_consecutive carries labels, areas, and slices across without recomputing them. """ segm = SegmentationImage(self.data.copy()) old_areas = segm.areas.copy() _ = segm.slices segm.relabel_consecutive() assert_equal(segm.labels, np.arange(len(old_areas)) + 1) assert 'areas' in segm.__dict__ assert_equal(segm.areas, old_areas) def test_reassign_labels_areas_recomputed(self): """ Test that reassign_labels leaves areas correct after labels are merged. """ segm = SegmentationImage(self.data.copy()) segm.reassign_label(label=3, new_label=1) expected = np.unique(segm.data[segm.data != 0], return_counts=True)[1] assert_equal(segm.areas, expected) def test_data_attribute_is_write_protected(self): """ Test that _data cannot be assigned directly, which would leave the derived properties stale. """ segm = SegmentationImage(self.data.copy()) match = 'Direct assignment to _data is not allowed' with pytest.raises(AttributeError, match=match): segm._data = np.zeros((3, 3), dtype=int) def test_get_slice_matches_slices(self): """ Test that _get_slice returns the same slice as indexing into the slices list by label index. """ segm = SegmentationImage(self.data.copy()) for label in segm.labels: idx = segm.get_index(label) assert segm._get_slice(label) == segm.slices[idx] def test_label_stats_called_once(self, monkeypatch): """ Test that the labels, areas, and bounding slices are measured by a single pass over the array, even when the slices and the per-label segments are used. """ calls = [] original = segm_core.label_stats def counting_label_stats(*args, **kwargs): calls.append(1) return original(*args, **kwargs) monkeypatch.setattr(segm_core, 'label_stats', counting_label_stats) segm = SegmentationImage(self.data.copy()) _ = segm.labels, segm.areas, segm.slices _ = segm.get_segment(int(segm.labels[0])) assert len(calls) == 1 def test_get_index_and_get_indices_agree(self): """ Test that get_index and get_indices return the same result for both scalar and array inputs. """ segm = SegmentationImage(self.data.copy()) labels = segm.labels assert_equal(segm.get_indices(labels), segm.get_index(labels)) assert segm.get_indices(labels[0]) == segm.get_index(labels[0]) assert np.ndim(segm.get_indices(labels[0])) == 0 def test_relabeling_preserves_info(self): """ Test that relabeling keeps the auxiliary info dictionary. Relabeling is an in-place change, not a data reassignment, so the deblending provenance stored in info still applies. """ for relabel in (lambda s: s.relabel_consecutive(), lambda s: s.reassign_label(label=3, new_label=1)): segm = SegmentationImage(self.data.copy()) segm.info['non_positive_min_labels'] = np.array([3, 5]) relabel(segm) assert_equal(segm.info['non_positive_min_labels'], [3, 5]) # Reassigning data does reset info segm = SegmentationImage(self.data.copy()) segm.info['non_positive_min_labels'] = np.array([3, 5]) segm.data = self.data.copy() assert segm.info == {} def test_set_data_seeds_derived_state(self): """ Test that _set_data seeds the derived cached properties and resets the mutable attributes. """ segm = SegmentationImage(self.data.copy()) segm.info['key'] = 'value' data2 = np.array([[2, 2, 0], [0, 0, 5], [5, 5, 0]]) labels = np.array([2, 5]) areas = np.array([2, 3]) slices = [(slice(0, 1), slice(0, 2)), (slice(1, 3), slice(0, 3))] segm._set_data(data2, labels=labels, areas=areas, slices=slices) assert_equal(segm.data, data2) assert_equal(segm.labels, labels) assert_equal(segm.areas, areas) assert segm.slices == slices assert segm.info == {} assert segm._deblend_label_map == {} # preserve_info keeps the info dictionary segm.info['key'] = 'value' segm._set_data(data2, preserve_info=True) assert segm.info == {'key': 'value'} def test_set_data_without_seeds(self): """ Test that _set_data computes the derived properties on demand when they are not supplied. """ segm = SegmentationImage(self.data.copy()) data2 = np.array([[2, 2, 0], [0, 0, 5], [5, 5, 0]]) segm._set_data(data2) assert_equal(segm.labels, [2, 5]) assert_equal(segm.areas, [2, 3]) assert len(segm.slices) == 2 def test_info(self): """ Test that the info attribute always exists and is reset when the data attribute is reassigned. """ segm = SegmentationImage(self.data.copy()) assert segm.info == {} segm.info['key'] = 'value' segm.data = self.data[0:3, :].copy() assert segm.info == {} def test_flags_default(self): """ Test that the flags property exists and is all zeros for a user-constructed segmentation image. """ segm = SegmentationImage(self.data.copy()) assert_equal(segm.flags, np.zeros(segm.n_labels, dtype=int)) def test_flags_reset_on_data_reassignment(self): """ Test that per-label flags are reset when the data attribute is reassigned. """ segm = SegmentationImage(self.data.copy()) segm._flags_map = {1: 3} segm.data = self.data.copy() assert segm._flags_map == {} assert_equal(segm.flags, np.zeros(segm.n_labels, dtype=int)) def test_flags_order_matches_labels(self): """ Test that the flags array is aligned with the labels array. """ segm = SegmentationImage(self.data.copy()) segm._flags_map = {3: 8, 1: 2} expected = [2 if label == 1 else 8 if label == 3 else 0 for label in segm.labels] assert_equal(segm.flags, expected) def test_flags_relabel_consecutive(self): """ Test that per-label flags follow their labels through consecutive relabeling. """ segm = SegmentationImage(self.data.copy()) old_labels = segm.labels.copy() segm._flags_map = {int(old_labels[-1]): 4} segm.relabel_consecutive() assert segm._flags_map == {segm.n_labels: 4} def test_flags_reassign_labels_merge(self): """ Test that merging labels combines their flags with bitwise OR and that removed labels drop their flags. """ segm = SegmentationImage(self.data.copy()) labels = segm.labels segm._flags_map = {int(labels[0]): 1, int(labels[1]): 2} segm.reassign_labels(labels[:2], int(labels[1])) assert segm._flags_map == {int(labels[1]): 3} def test_flags_remove_labels(self): """ Test that removing a label drops its flags entry. """ segm = SegmentationImage(self.data.copy()) labels = segm.labels segm._flags_map = {int(labels[0]): 1, int(labels[1]): 2} segm.remove_labels(labels[0]) assert segm._flags_map == {int(labels[1]): 2} def test_flags_copy(self): """ Test that copying a segmentation image copies the flags mapping without sharing state. """ segm = SegmentationImage(self.data.copy()) segm._flags_map = {1: 1} segm2 = segm.copy() assert segm2._flags_map == {1: 1} segm2._flags_map[1] = 2 assert segm._flags_map == {1: 1} def test_flags_not_settable(self): """ Test that the flags property cannot be assigned. """ segm = SegmentationImage(self.data.copy()) match = 'has no setter|can.t set attribute|object attribute' with pytest.raises(AttributeError, match=match): segm.flags = np.zeros(segm.n_labels, dtype=int) def test_flags_empty_labels(self): """ Test that the flags array has an integer dtype and zero length for a segmentation image with no labels. """ segm = SegmentationImage(self.data.copy()) segm.remove_labels(segm.labels) assert segm.n_labels == 0 assert segm.flags.dtype.kind == 'i' assert len(segm.flags) == 0 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) # Is a masked array data = np.ma.masked_array([[1, 1], [0, 1]], mask=[[False, True], [False, False]]) match = 'Input data must not be a numpy masked 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. The message must show plain integers, not numpy scalar reprs. """ match = r'labels \[-1, 0, 2\] 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 cached property returns the correct shape. """ assert self.segm.shape == (6, 6) def test_cached_properties_class_cache(self): """ Test that _cached_properties is cached on the class and shared across instances. """ segm2 = SegmentationImage(self.data.copy()) result1 = self.segm._cached_properties result2 = segm2._cached_properties 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 @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) @pytest.mark.parametrize('border_width', [0, -1]) def test_remove_border_labels_nonpositive_border_width(self, border_width): """ Test that remove_border_labels raises for nonpositive border widths instead of removing every label. """ segm = SegmentationImage(self.data.copy()) match = 'border_width must be a positive integer' with pytest.raises(ValueError, match=match): segm.remove_border_labels(border_width=border_width) 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) @pytest.mark.parametrize('as_list', [False, True]) @pytest.mark.parametrize('partial_overlap', [True, False]) def test_remove_masked_labels_non_bool_mask(self, as_list, partial_overlap): """ Test that a list mask and a 0/1 integer mask are used as a boolean mask and not as a fancy index. """ bool_mask = np.zeros(self.data.shape, dtype=bool) bool_mask[2:, 3:] = True ref = SegmentationImage(self.data.copy()) ref.remove_masked_labels(bool_mask, partial_overlap=partial_overlap) mask = bool_mask.astype(int) if as_list: mask = mask.tolist() segm = SegmentationImage(self.data.copy()) segm.remove_masked_labels(mask, partial_overlap=partial_overlap) assert_equal(segm.data, ref.data) expected = [1, 4, 7] if partial_overlap else [1, 3, 4, 7] assert_equal(segm.labels, expected) @pytest.mark.parametrize('method', ['reassign_labels', 'remove_labels', 'keep_label', 'keep_labels', 'remove_border_labels', 'remove_masked_labels']) def test_relabel_nothing_removed(self, method): """ Test that relabel=True relabels the image consecutively when no label is reassigned or removed. """ if method == 'keep_label': data = self.data * (self.data == 7) else: data = np.zeros((8, 8), dtype=int) data[1:7, 1:7] = self.data segm = SegmentationImage(data) labels = segm.labels ref = segm.copy() ref.relabel_consecutive() if method == 'reassign_labels': segm.reassign_labels([], 99, relabel=True) elif method == 'remove_labels': segm.remove_labels([], relabel=True) elif method == 'keep_label': segm.keep_label(7, relabel=True) elif method == 'keep_labels': segm.keep_labels(labels, relabel=True) elif method == 'remove_border_labels': segm.remove_border_labels(1, relabel=True) else: mask = np.zeros(data.shape, dtype=bool) segm.remove_masked_labels(mask, relabel=True) assert_equal(segm.labels, np.arange(len(labels)) + 1) assert_equal(segm.data, ref.data) @pytest.mark.parametrize('method', ['reassign_labels', 'remove_labels', 'keep_labels', 'remove_border_labels', 'remove_masked_labels']) def test_relabel_no_labels(self, method): """ Test that relabel=True on an image with no labels leaves it unchanged without a warning. """ segm = SegmentationImage(np.zeros((6, 6), dtype=int)) with warnings.catch_warnings(): warnings.simplefilter('error') if method == 'reassign_labels': segm.reassign_labels([], 99, relabel=True) elif method == 'remove_labels': segm.remove_labels([], relabel=True) elif method == 'keep_labels': segm.keep_labels([], relabel=True) elif method == 'remove_border_labels': segm.remove_border_labels(1, relabel=True) else: mask = np.ones(segm.shape, dtype=bool) segm.remove_masked_labels(mask, relabel=True) assert segm.n_labels == 0 assert_equal(segm.data, 0) @pytest.mark.parametrize('new_label', [-1, 2.5, np.nan, 'a', [1, 2], None]) def test_reassign_labels_invalid_new_label(self, new_label): """ Test that a negative or noninteger new label raises an error and leaves the image unchanged. """ segm = SegmentationImage(self.data.copy()) match = 'new_label must be a nonnegative integer' with pytest.raises(ValueError, match=match): segm.reassign_label(1, new_label) with pytest.raises(ValueError, match=match): segm.reassign_labels([1, 3], new_label, relabel=True) assert_equal(segm.data, self.data) @pytest.mark.parametrize(('dtype', 'new_label'), [(np.uint8, 256), (np.int16, 2**15), (np.int64, 2**63)]) def test_reassign_labels_new_label_dtype(self, dtype, new_label): """ Test that a new label that the dtype of the segmentation array cannot hold raises an error, and that the largest value it can hold is accepted. """ segm = SegmentationImage(self.data.astype(dtype)) match = 'cannot be represented' with pytest.raises(ValueError, match=match): segm.reassign_label(1, new_label) with pytest.raises(ValueError, match=match): segm.reassign_labels([1, 3], new_label) assert_equal(segm.data, self.data) segm.reassign_label(1, new_label - 1) assert segm.data.dtype == dtype assert segm.max_label == new_label - 1 def test_reassign_labels_integer_valued_new_label(self): """ Test that zero, NumPy integers, and integer-valued floats are valid new labels. """ segm = SegmentationImage(self.data.copy()) segm.reassign_label(1, np.int16(9)) segm.reassign_label(3, 11.0) segm.reassign_label(4, 0) assert_equal(segm.labels, [5, 7, 9, 11]) 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_SHAPELY, reason='shapely is required') @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize('labels', [None, 3, (4, 7)]) def test_plot_patches_returns_added_patches(self, labels): """ Test that plot_patches returns the patches that were added to the axes. """ import matplotlib.pyplot as plt _, ax = plt.subplots() patches = self.segm.plot_patches(ax=ax, labels=labels) assert isinstance(patches, list) assert len(patches) == len(ax.patches) for plotted, ax_patch in zip(patches, ax.patches, strict=True): assert plotted is ax_patch plt.close(ax.figure) @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 noncontiguous. """ 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) @pytest.fixture def deblended_segm(): """ A deblended segmentation image of three blended Gaussian sources and one isolated source. The blend is label 1 in the detection image and labels 2, 3, and 4 in the deblended image. The isolated source is label 1 in the deblended image. """ yy, xx = np.mgrid[0:25, 0:60] data = np.zeros(yy.shape) for x0 in (10, 17, 24, 45): data += 100.0 * np.exp(-((xx - x0)**2 + (yy - 12)**2) / 8.0) segm = detect_sources(data, 1.0, n_pixels=5) return deblend_sources(data, segm, n_pixels=5) class TestDeblendMapsAfterRelabel: """ Tests for the deblended label maps after labels are removed or merged. """ def test_fixture(self, deblended_segm): """ Test the deblended label maps of the fixture. """ segm = deblended_segm assert_equal(segm.labels, [1, 2, 3, 4]) assert_equal(segm.deblended_labels, [2, 3, 4]) assert segm.deblended_label_to_parent == {2: 1, 3: 1, 4: 1} assert list(segm.parent_to_deblended_labels) == [1] assert_equal(segm.parent_to_deblended_labels[1], [2, 3, 4]) @pytest.mark.parametrize('relabel', [False, True]) def test_remove_child(self, deblended_segm, relabel): """ Test that a removed deblended label is dropped from the maps. """ segm = deblended_segm segm.remove_label(2, relabel=relabel) children = [2, 3] if relabel else [3, 4] assert_equal(segm.deblended_labels, children) assert segm.deblended_label_to_parent == dict.fromkeys(children, 1) assert list(segm.parent_to_deblended_labels) == [1] assert_equal(segm.parent_to_deblended_labels[1], children) assert np.all(np.isin(segm.deblended_labels, segm.labels)) def test_remove_all_children(self, deblended_segm): """ Test that a parent with no deblended labels left is dropped from the maps. """ segm = deblended_segm segm.remove_labels([2, 3, 4]) assert_equal(segm.labels, [1]) assert segm.deblended_labels.size == 0 assert segm.deblended_label_to_parent == {} assert segm.parent_to_deblended_labels == {} def test_merge_children_of_two_parents(self): """ Test that a label merged from the deblended labels of two parents is kept under both parents and is listed once in the deblended labels. """ yy, xx = np.mgrid[0:60, 0:40] data = np.zeros(yy.shape) for x0, y0 in ((10, 12), (17, 12), (10, 45), (17, 45)): data += 100.0 * np.exp(-((xx - x0)**2 + (yy - y0)**2) / 8.0) segm = detect_sources(data, 1.0, n_pixels=5) segm = deblend_sources(data, segm, n_pixels=5) assert_equal(segm.deblended_labels, [1, 2, 3, 4]) assert list(segm.parent_to_deblended_labels) == [1, 2] segm.reassign_labels([2, 3], 2) assert_equal(segm.labels, [1, 2, 4]) assert_equal(segm.deblended_labels, [1, 2, 4]) assert_equal(segm.parent_to_deblended_labels[1], [1, 2]) assert_equal(segm.parent_to_deblended_labels[2], [2, 4]) assert segm.deblended_label_to_parent == {1: 1, 2: 2, 4: 2} def test_keep_non_deblended(self, deblended_segm): """ Test that the maps are empty when only a label that was not deblended is kept. """ segm = deblended_segm segm.keep_label(1, relabel=True) assert segm.deblended_labels.size == 0 assert segm.deblended_label_to_parent == {} assert segm.parent_to_deblended_labels == {} def test_merge_children(self, deblended_segm): """ Test that merged deblended labels appear once in the maps. """ segm = deblended_segm segm.reassign_labels([2, 3], 4) assert_equal(segm.labels, [1, 4]) assert_equal(segm.deblended_labels, [4]) assert segm.deblended_label_to_parent == {4: 1} assert_equal(segm.parent_to_deblended_labels[1], [4]) def test_merge_children_keeps_order(self, deblended_segm): """ Test that the deblended labels of a parent keep their order when two of them are merged. """ segm = deblended_segm segm.reassign_labels([2, 4], 9) assert_equal(segm.parent_to_deblended_labels[1], [9, 3]) assert_equal(segm.deblended_labels, [3, 9]) assert segm.deblended_label_to_parent == {9: 1, 3: 1} class CustomSegm(SegmentationImage): @cached_property 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 # Only _data, labels, areas, _label_stats, _deblend_label_map, # _flags_map, and info remain assert len(segm.__dict__) == 7 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._from_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_max_labels_excludes_background(segm_data): """ Test that the background value is not counted against max_labels. """ segm = SegmentationImage(segm_data) with warnings.catch_warnings(): warnings.simplefilter('error') _im, cbar_info = segm.imshow_map(max_labels=segm.n_labels) assert cbar_info is not None assert_equal(cbar_info[2], [0, 1, 3, 4, 5, 7]) match = 'The colorbar was not plotted' with pytest.warns(AstropyUserWarning, match=match): _im, cbar_info = segm.imshow_map(max_labels=segm.n_labels - 1) assert cbar_info is None @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_imshow_map_no_labels(): """ Test that imshow_map displays an image with no labels and returns no colorbar information. """ from matplotlib.image import AxesImage segm = SegmentationImage(np.zeros((4, 5), dtype=int)) im, cbar_info = segm.imshow_map() assert isinstance(im, AxesImage) assert cbar_info is None assert_equal(np.asarray(im.get_array()), segm.data) assert len(im.axes.figure.axes) == 1 @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 _cbar, ticks, labels = cbar_info assert_equal(labels, [0, 1, 3, 4, 5, 7]) assert_equal(ticks, np.arange(len(labels))) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_imshow_map_no_background(): """ Test imshow_map colorbar labels for a segmentation image with no background (zero) pixels. Regression test for a bug where 0 was unconditionally prepended to the colorbar tick labels, shifting every label by one position when the segmentation image contained no background pixels. """ data = np.array([[1, 1, 2, 2], [1, 1, 2, 2], [5, 5, 9, 9], [5, 5, 9, 9]]) segm = SegmentationImage(data) im, cbar_info = segm.imshow_map() assert cbar_info is not None _cbar, ticks, labels = cbar_info assert_equal(labels, [1, 2, 5, 9]) assert_equal(ticks, np.arange(len(labels))) assert np.max(np.asarray(im.get_array())) == len(labels) - 1 class TestGetLabelMapping: """ Tests for the get_label_mapping method. """ @pytest.fixture(autouse=True) def setup(self, segm_data): self.parent = SegmentationImage(segm_data) # A deblend-like version of segm_data with the identical # nonzero footprint. Label 5 is split into labels 8 and 9, # and label 7 is split into labels 10 and 11. child_data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [10, 0, 0, 0, 0, 8], [10, 10, 0, 8, 8, 9], [10, 11, 0, 0, 9, 9]]) self.child = SegmentationImage(child_data) def test_basic(self): """ Test the full parent-to-child mapping. """ mapping = self.parent.get_label_mapping(self.child) expected = {1: [1], 3: [3], 4: [4], 5: [8, 9], 7: [10, 11]} assert list(mapping.keys()) == list(expected.keys()) for label, child_labels in expected.items(): assert_equal(mapping[label], child_labels) def test_key_and_value_types(self): """ Test that the keys are Python ints and the values are sorted arrays with the dtype of the other segmentation image. """ mapping = self.parent.get_label_mapping(self.child) assert all(isinstance(key, int) for key in mapping) for value in mapping.values(): assert isinstance(value, np.ndarray) assert value.dtype == self.child.data.dtype assert_equal(value, np.sort(value)) def test_identity(self): """ Test that mapping a segmentation image to itself maps every label to itself. """ mapping = self.parent.get_label_mapping(self.parent) for label, mapped in mapping.items(): assert_equal(mapped, [label]) def test_inverse(self): """ Test the child-to-parent mapping. """ mapping = self.child.get_label_mapping(self.parent) expected = {1: [1], 3: [3], 4: [4], 8: [5], 9: [5], 10: [7], 11: [7]} assert list(mapping.keys()) == list(expected.keys()) for label, parent_labels in expected.items(): assert_equal(mapping[label], parent_labels) def test_labels_scalar(self): """ Test mapping a single scalar label. """ mapping = self.parent.get_label_mapping(self.child, labels=5) assert list(mapping.keys()) == [5] assert_equal(mapping[5], [8, 9]) def test_labels_subset(self): """ Test that a labels subset preserves the input label order. """ mapping = self.parent.get_label_mapping(self.child, labels=[7, 1]) assert list(mapping.keys()) == [7, 1] assert_equal(mapping[7], [10, 11]) assert_equal(mapping[1], [1]) def test_labels_empty(self): """ Test that an empty labels list returns an empty dict. """ assert self.parent.get_label_mapping(self.child, labels=[]) == {} def test_labels_invalid(self): """ Test that invalid labels raise a ValueError. """ match = r'label \[2\] is invalid' with pytest.raises(ValueError, match=match): self.parent.get_label_mapping(self.child, labels=2) def test_labels_positional(self): """ Test that labels cannot be passed as a positional argument. """ match = 'positional argument' with pytest.raises(TypeError, match=match): self.parent.get_label_mapping(self.child, [5]) def test_other_invalid_type(self): """ Test that a non-SegmentationImage other raises a TypeError. """ match = 'must be a SegmentationImage instance' with pytest.raises(TypeError, match=match): self.parent.get_label_mapping(self.child.data) def test_other_shape_mismatch(self): """ Test that a shape mismatch raises a ValueError. """ segm = SegmentationImage(np.array([[1, 1], [0, 2]])) match = 'must have the same shape' with pytest.raises(ValueError, match=match): self.parent.get_label_mapping(segm) def test_all_zeros(self): """ Test that a segmentation image with no labels returns an empty dict. """ segm = SegmentationImage(np.zeros((6, 6), dtype=int)) assert segm.get_label_mapping(self.child) == {} def test_other_all_zeros(self): """ Test that every label maps to the background when the other segmentation image is all zeros. """ segm = SegmentationImage(np.zeros((6, 6), dtype=int)) mapping = self.parent.get_label_mapping(segm) assert list(mapping.keys()) == [1, 3, 4, 5, 7] for mapped in mapping.values(): assert_equal(mapped, [0]) def test_footprint_mismatch(self): """ Test that zero appears in the mapped labels where the other segmentation image has background pixels within a labeled region. """ child_data = self.child.data.copy() child_data[0, 0] = 0 child = SegmentationImage(child_data) mapping = self.parent.get_label_mapping(child) assert_equal(mapping[1], [0, 1]) @pytest.mark.parametrize(('dtype', 'offset'), [(np.int64, 2**32), (np.int64, 2**62), (np.uint64, 2**63)]) def test_large_labels(self, dtype, offset): """ Test the mapping for label values of 2**31 or more, whose packed (parent, child) keys do not fit in an int64. """ parent_data = self.parent.data.astype(dtype) parent_data[parent_data > 3] += dtype(offset) child_data = self.child.data.astype(dtype) child_data[child_data > 2] += dtype(offset + 100) child_data[0, 0] = 0 parent = SegmentationImage(parent_data) child = SegmentationImage(child_data) expected = {} for label in parent.labels: expected[int(label)] = np.unique(child_data[parent_data == label]) mapping = parent.get_label_mapping(child) assert list(mapping) == list(expected) for label, children in mapping.items(): assert isinstance(label, int) assert children.dtype == dtype assert_equal(children, expected[label]) label = int(parent.labels[-1]) mapping = parent.get_label_mapping(child, labels=label) assert list(mapping) == [label] assert_equal(mapping[label], expected[label]) 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 noncontiguous 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 nonscalar 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 nonscalar 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') class TestLabelAboveInt32: """ Tests for the single-label polygon, patch, and region methods with a label above the int32 range. """ @pytest.fixture(autouse=True) def setup(self, segm_data): self.label = 2**40 data = segm_data.astype(np.int64) data[data == 7] = self.label self.segm = SegmentationImage(data) self.ref = SegmentationImage(segm_data) def test_get_polygon(self): """ Test that get_polygon and get_polygons return the polygon. """ ref_poly = self.ref.get_polygon(7) assert self.segm.get_polygon(self.label).equals(ref_poly) polygons = self.segm.get_polygons([1, self.label]) assert polygons[0].equals(self.ref.get_polygon(1)) assert polygons[1].equals(ref_poly) def test_get_segment(self): """ Test that get_segment returns a segment with its polygon. """ segment = self.segm.get_segment(self.label) assert segment.polygon.equals(self.ref.get_polygon(7)) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_get_patch(self): """ Test that get_patch and get_patches return the patch. """ ref_verts = self.ref.get_patch(7).get_verts() assert_allclose(self.segm.get_patch(self.label).get_verts(), ref_verts) patches = self.segm.get_patches([1, self.label]) assert_allclose(patches[1].get_verts(), ref_verts) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_get_region(self): """ Test that get_region and get_regions return the region. """ ref_region = self.ref.get_region(7) region = self.segm.get_region(self.label) assert_allclose(region.vertices.xy, ref_region.vertices.xy) assert region.meta['label'] == self.label regions = self.segm.get_regions([1, self.label]) assert_allclose(regions[1].vertices.xy, ref_region.vertices.xy) @pytest.mark.parametrize('missing', ['HAS_RASTERIO', 'HAS_SHAPELY']) class TestMissingPolygonDependency: """ Tests for the single-label patch and region methods when rasterio or shapely is not installed. """ @pytest.fixture(autouse=True) def setup(self, segm_data, monkeypatch, missing): monkeypatch.setattr(segm_core, missing, False) self.segm = SegmentationImage(segm_data) def test_get_patch(self): """ Test that get_patch returns None. """ assert self.segm.get_patch(1) is None def test_get_patches(self): """ Test that get_patches returns a list of None. """ assert self.segm.get_patches([1, 3]) == [None, None] assert self.segm.get_patches(5) == [None] def test_get_region(self): """ Test that get_region returns None. """ assert self.segm.get_region(1) is None def test_get_regions(self): """ Test that get_regions returns a list of None. """ assert self.segm.get_regions([1, 3]) == [None, None] assert self.segm.get_regions(5) == [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_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 nonscalar 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 noncontiguous (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 class TestThreadSafety: """ Thread-safety tests for the SegmentationImage class. """ @pytest.fixture(autouse=True) def setup(self, segm_data): self.data = segm_data def test_concurrent_cached_property_access(self): """ Test concurrent first access of cached properties on a shared segmentation image. cached_property does not lock, so concurrent first accesses may run a getter more than once, but every thread must see values identical to the serial computation. """ expected = SegmentationImage(self.data.copy()) segm = SegmentationImage(self.data.copy()) n_threads = 8 barrier = threading.Barrier(n_threads) def worker(_): barrier.wait() return (segm.labels, segm.areas, segm.slices, segm.missing_labels) with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, range(n_threads))) for labels, areas, slices, missing_labels in results: assert_equal(labels, expected.labels) assert_equal(areas, expected.areas) assert slices == expected.slices assert_equal(missing_labels, expected.missing_labels) def test_concurrent_cached_properties_introspection(self): """ Test concurrent first access of the class-level _cached_properties introspection cache on a fresh subclass. The lazy class-level cache is written without a lock. The race is benign because the computed list is identical for every thread. """ class FreshSegm(SegmentationImage): pass segms = [FreshSegm(self.data.copy()) for _ in range(8)] n_threads = len(segms) barrier = threading.Barrier(n_threads) def worker(segm): barrier.wait() return segm._cached_properties with ThreadPoolExecutor(max_workers=n_threads) as executor: results = list(executor.map(worker, segms)) assert all(result == results[0] for result in results) assert 'areas' in results[0] def test_segment_deprecations(segm_data): segment_map = SegmentationImage(segm_data) segments = segment_map.segments match = 'attribute was deprecated' with pytest.warns(PhotutilsDeprecationWarning, match=match): _ = segments[0].data_ma def _make_label_array(): """ Make a segmentation array with nonconsecutive labels, labels that touch every edge, a label split into two regions, and a label that surrounds another. """ data = np.zeros((40, 50), dtype=int) data[0:3, 0:4] = 7 data[37:40, 46:50] = 7 # second region of label 7 data[10:20, 10:25] = 3 data[13:16, 14:18] = 90 # inside label 3 data[30, 5:45] = 12 data[5:35, 49] = 120 data[22, 30] = 1 return data @pytest.mark.parametrize('dtype', ['i1', 'u1', 'i2', 'u2', 'i4', 'u4', 'i8', 'u8', '>i4', '>i8']) @pytest.mark.parametrize('layout', ['C', 'F', 'strided', 'read-only']) def test_label_stats_matches_numpy(dtype, layout): """ Test that the labels, areas, and slices of a segmentation image are identical to those of numpy.unique and scipy find_objects for every integer dtype and memory layout, both for a validated and a prevalidated array. """ data = _make_label_array().astype(dtype) if layout == 'F': data = np.asfortranarray(data) elif layout == 'strided': data = np.repeat(np.repeat(data, 2, axis=0), 2, axis=1)[::2, ::2] elif layout == 'read-only': data.setflags(write=False) labels, areas = np.unique(data[data != 0], return_counts=True) slices = [slc for slc in find_objects(data) if slc is not None] for segm in (SegmentationImage(data), SegmentationImage._from_data(data)): assert_equal(segm.labels, labels) assert segm.labels.dtype == labels.dtype assert_equal(segm.areas, areas) assert segm.areas.dtype == areas.dtype assert segm.slices == slices for slc in segm.slices: for item in slc: assert type(item.start) is int assert type(item.stop) is int assert item.step is None @pytest.mark.parametrize('dtype', [np.int32, np.int64]) def test_label_stats_kernel(dtype): """ Test the per-label counts and bounding boxes of the kernel, and that it declines arrays with negative or too-large labels. """ data = _make_label_array().astype(dtype) vmin, vmax, counts, ymin, ymax, xmin, xmax = label_stats(data, 1000) assert (vmin, vmax) == (0, 120) assert_equal(counts, np.bincount(data.ravel(), minlength=121) * (np.arange(121) > 0)) for label, slc in enumerate(find_objects(data), start=1): if slc is None: assert counts[label] == 0 continue assert (ymin[label], ymax[label] + 1) == (slc[0].start, slc[0].stop) assert (xmin[label], xmax[label] + 1) == (slc[1].start, slc[1].stop) # A label equal to the limit result = label_stats(data, 120) assert result[:2] == (0, 120) assert_equal(result[2], counts) for item, expected in zip(result[3:], (ymin, ymax, xmin, xmax), strict=True): assert_equal(item[counts > 0], expected[counts > 0]) # A label above the limit result = label_stats(data, 119) assert result[:2] == (0, 120) assert all(item is None for item in result[2:]) # A negative value data[0, 10] = -4 result = label_stats(data, 1000) assert result[:2] == (-4, 120) assert all(item is None for item in result[2:]) # A zero-size array result = label_stats(np.zeros((0, 5), dtype=dtype), 1000) assert result[:2] == (0, 0) assert all(item is None for item in result[2:]) def test_label_stats_fallback(): """ Test that arrays the kernel does not measure directly (very large labels, unsigned 64-bit labels, and arrays that are not 2D) give the same labels, areas, and slices. """ data = _make_label_array() labels, areas = np.unique(data[data != 0], return_counts=True) slices = [slc for slc in find_objects(data) if slc is not None] # A label above the limit of the kernel large = data.astype(np.int32) large[large == 120] = 70000 segm = SegmentationImage(large) assert_equal(segm.labels, [1, 3, 7, 12, 90, 70000]) assert segm.labels.dtype == np.int32 assert_equal(segm.areas, areas) assert segm.slices == [slc for slc in find_objects(large) if slc is not None] # Unsigned 64-bit labels, including one above the int64 range data_u8 = data.astype(np.uint64) segm = SegmentationImage(data_u8) assert_equal(segm.labels, labels) assert segm.labels.dtype == np.uint64 assert_equal(segm.areas, areas) assert segm.slices == slices data_u8[data_u8 == 120] = 2**64 - 1 segm = SegmentationImage(data_u8) assert segm.labels[-1] == 2**64 - 1 assert_equal(segm.areas, areas) assert segm.slices == slices # A 3D array cube = np.stack((data, data, np.zeros_like(data))) segm = SegmentationImage(cube) assert segm._label_stats[2] is None assert_equal(segm.labels, labels) assert_equal(segm.areas, 2 * areas) assert segm.slices == [slc for slc in find_objects(cube) if slc is not None] # An array with no labels segm = SegmentationImage(np.zeros((4, 5), dtype=int)) assert segm.n_labels == 0 assert len(segm.areas) == 0 assert segm.slices == [] @pytest.mark.parametrize('dtype', [np.int32, np.int64, np.uint64]) @pytest.mark.parametrize('shape', [(0, 5), (5, 0), (0, 0), (0, 4, 5)]) def test_zero_size_array(dtype, shape): """ Regression test that a zero-size segmentation array has no labels, areas, or slices. scipy find_objects raises an error for a zero-size array. """ data = np.zeros(shape, dtype=dtype) for segm in (SegmentationImage(data), SegmentationImage._from_data(data)): assert segm.n_labels == 0 assert len(segm.areas) == 0 assert segm.slices == [] def test_label_stats_uint64_small_labels(monkeypatch): """ Test that an unsigned 64-bit array whose labels fit in the per-label arrays is measured directly by the kernel, without first being mapped to consecutive labels. """ calls = [] original = segm_core.label_stats def recording_label_stats(segm, max_label_limit): calls.append((segm.dtype, max_label_limit)) return original(segm, max_label_limit) monkeypatch.setattr(segm_core, 'label_stats', recording_label_stats) data = _make_label_array() expected = SegmentationImage(data) calls.clear() segm = SegmentationImage(data.astype(np.uint64)) assert calls == [(np.dtype(np.int64), 65536)] assert_equal(segm.labels, expected.labels) assert segm.labels.dtype == np.uint64 assert_equal(segm.areas, expected.areas) assert segm.slices == expected.slices # A zero-size array has no maximum value segm = SegmentationImage._from_data(np.zeros((0, 5), dtype=np.uint64)) assert segm.n_labels == 0 assert len(segm.areas) == 0 # Other arrays are cast to the smallest kernel dtype that holds them for dtype, kernel_dtype in (('u2', np.int32), ('>i4', np.int32), ('u4', np.int64), ('>i8', np.int64)): calls.clear() segm = SegmentationImage(data.astype(dtype)) assert calls == [(np.dtype(kernel_dtype), 65536)] assert_equal(segm.labels, expected.labels) def _make_huge_label_pair(dtype): """ Return a segmentation image with ordinary labels and one with the same segments but with labels near 2**40, in the same order. """ data = _make_label_array() huge = data.astype(dtype) for label in np.unique(data[data != 0]): huge[data == label] = 2**40 + 1000 * int(label) return SegmentationImage(data), SegmentationImage(huge) @pytest.mark.parametrize('dtype', [np.int64, np.uint64]) def test_huge_labels(dtype): """ Regression test that the slices of an image with huge label values are measured with memory set by the number of labels. scipy find_objects allocates one entry for every label value up to the maximum, so it runs out of memory for such labels. """ # The label order is kept, so the slices order is unchanged expected, segm_huge = _make_huge_label_pair(dtype) huge = segm_huge.data for segm in (SegmentationImage(huge), SegmentationImage._from_data(huge)): assert_equal(segm.labels, 2**40 + 1000 * expected.labels.astype(np.int64)) assert segm.labels.dtype == dtype assert_equal(segm.areas, expected.areas) assert segm.slices == expected.slices assert type(segm.slices[0][0].start) is int assert [bbox.shape for bbox in segm.bbox] == [ bbox.shape for bbox in expected.bbox] segment = segm.get_segment(int(segm.labels[1])) assert segment.area == expected.areas[1] @pytest.mark.parametrize('dtype', [np.int32, np.int64, np.uint64]) @pytest.mark.parametrize('layout', ['C', 'F', 'strided', 'read-only']) def test_large_labels_layout(dtype, layout): """ Test that an array whose labels are mapped to consecutive labels before it is measured gives the labels, areas, and slices of the same array with small labels, for every memory layout. """ data = _make_label_array() expected = SegmentationImage(data) offset = 10**6 large = np.where(data > 0, data + offset, 0).astype(dtype) if layout == 'F': large = np.asfortranarray(large) elif layout == 'strided': large = np.repeat(np.repeat(large, 2, axis=0), 2, axis=1)[::2, ::2] elif layout == 'read-only': large.setflags(write=False) for segm in (SegmentationImage(large), SegmentationImage._from_data(large)): assert_equal(segm.labels, expected.labels + offset) assert segm.labels.dtype == dtype assert_equal(segm.areas, expected.areas) assert segm.areas.dtype == expected.areas.dtype assert segm.slices == expected.slices @pytest.mark.parametrize('dtype', [np.int64, np.uint64]) def test_huge_labels_relabel(dtype): """ Regression test that the relabeling methods work for an image with huge label values, with memory set by the number of labels. The relabeling maps were arrays with one element for every label value up to the maximum, so they ran out of memory for such labels. """ ref, segm = _make_huge_label_pair(dtype) ref_labels = ref.labels labels = segm.labels segm.relabel_consecutive() ref.relabel_consecutive() assert_equal(segm.data, ref.data) assert segm.data.dtype == dtype assert segm.slices == ref.slices ref, segm = _make_huge_label_pair(dtype) segm.relabel_consecutive(start_label=2**41) assert_equal(segm.labels, 2**41 + np.arange(len(labels), dtype=np.int64)) assert_equal(segm.areas, ref.areas) for relabel in (False, True): ref, segm = _make_huge_label_pair(dtype) segm.reassign_labels(labels[[1, 2]], new_label=int(labels[0]), relabel=relabel) ref.reassign_labels(ref_labels[[1, 2]], new_label=int(ref_labels[0]), relabel=relabel) assert_equal(segm.data != 0, ref.data != 0) assert_equal(segm.areas, ref.areas) if relabel: assert_equal(segm.data, ref.data) else: assert_equal(segm.labels, labels[[0, 3, 4, 5]]) ref, segm = _make_huge_label_pair(dtype) segm.remove_labels(labels[::2], relabel=relabel) ref.remove_labels(ref_labels[::2], relabel=relabel) assert_equal(segm.areas, ref.areas) assert_equal(segm.labels, ref.labels if relabel else labels[1::2]) ref, segm = _make_huge_label_pair(dtype) segm.keep_labels(labels[::2], relabel=relabel) ref.keep_labels(ref_labels[::2], relabel=relabel) assert_equal(segm.areas, ref.areas) assert_equal(segm.labels, ref.labels if relabel else labels[::2]) # The offsets of the labels of _make_label_array that fit in each dtype _RELABEL_DTYPE_OFFSETS = [ (dtype, offset) for dtype in ('i2', 'u4', 'i8', 'u8', '>i2', '>i4', '>u4', '>i8', '>u8') for offset in (0, 10000, 10**6, 2**40) if offset + 120 <= np.iinfo(dtype).max] @pytest.mark.parametrize(('dtype', 'offset'), _RELABEL_DTYPE_OFFSETS) def test_relabel_preserves_dtype(dtype, offset): """ Test that the relabeling methods keep the dtype of the segmentation array, including its byte order, both for labels that are relabeled with an array indexed by the label value and for larger labels. """ dtype = np.dtype(dtype) data = _make_label_array() ref_labels = SegmentationImage(data).labels data = np.where(data > 0, data.astype(np.int64) + offset, 0).astype(dtype) labels = SegmentationImage(data).labels assert labels.dtype == dtype def relabel_consecutive(segm, ref): segm.relabel_consecutive() ref.relabel_consecutive() def relabel_consecutive_start(segm, ref): segm.relabel_consecutive(start_label=5) ref.relabel_consecutive(start_label=5) def reassign_labels(segm, ref, relabel): segm.reassign_labels(labels[[1, 2]], new_label=int(labels[0]), relabel=relabel) ref.reassign_labels(ref_labels[[1, 2]], new_label=int(ref_labels[0]), relabel=relabel) def keep_labels(segm, ref, relabel): segm.keep_labels(labels[::2], relabel=relabel) ref.keep_labels(ref_labels[::2], relabel=relabel) def remove_labels(segm, ref, relabel): segm.remove_labels(labels[::2], relabel=relabel) ref.remove_labels(ref_labels[::2], relabel=relabel) funcs = [relabel_consecutive, relabel_consecutive_start] for func in (reassign_labels, keep_labels, remove_labels): funcs.extend([partial(func, relabel=False), partial(func, relabel=True)]) for func in funcs: segm = SegmentationImage(data.copy()) ref = SegmentationImage(_make_label_array()) func(segm, ref) assert segm.data.dtype == dtype assert segm.data.dtype.byteorder == dtype.byteorder assert segm.labels.dtype == dtype assert segm.labels.dtype.byteorder == dtype.byteorder assert_equal(segm.data != 0, ref.data != 0) assert_equal(segm.areas, ref.areas) assert segm.slices == ref.slices def test_huge_labels_relabel_maps(): """ Test that relabeling an image with huge label values translates the deblending label map and the per-label flags. """ ref, segm = _make_huge_label_pair(np.int64) labels = segm.labels segm._deblend_label_map = {5: labels[[1, 4]]} segm._flags_map = {int(labels[1]): 1, int(labels[4]): 3, int(labels[5]): 4} segm.relabel_consecutive() assert_equal(segm._deblend_label_map[5], [2, 5]) assert segm._flags_map == {2: 1, 5: 3, 6: 4} ref.relabel_consecutive() assert_equal(segm.data, ref.data) @pytest.mark.parametrize('dtype', [np.int32, np.int64, np.uint64]) def test_sparse_relabel_map(dtype): """ Test that the sparse relabeling map returns the same values as the dense array for labels, and zero for every other value. """ labels = np.array([3, 4, 17, 250, 60000], dtype=dtype) new_labels = np.array([9, 1, 0, 7, 2], dtype=dtype) dense = segm_core._make_relabel_map(labels, new_labels, 10**7) assert isinstance(dense, np.ndarray) sparse = segm_core._SparseRelabelMap(labels, new_labels) index = np.array([[0, 3, 4], [17, 250, 60000], [5, 1, 59999]], dtype=dtype) result = sparse[index] assert_equal(result, dense[index]) assert result.dtype == dtype assert result.shape == index.shape for label in (0, 3, 250, 60000, 2): assert int(sparse[label]) == int(dense[label]) assert int(sparse[60001]) == 0 assert_equal(sparse[labels.astype(np.int64)], new_labels) # A small array with large labels gets the sparse map sparse = segm_core._make_relabel_map(labels + 2**20, new_labels, 100) assert isinstance(sparse, segm_core._SparseRelabelMap) assert_equal(sparse[labels + 2**20], new_labels) def test_label_stats_reset_on_reassignment(): """ Test that the slices follow the data when it is reassigned or relabeled. """ data = _make_label_array() segm = SegmentationImage(data) _ = segm.slices data2 = np.zeros_like(data) data2[5:9, 6:11] = 4 segm.data = data2 assert_equal(segm.labels, [4]) assert segm.slices == [(slice(5, 9), slice(6, 11))] segm = SegmentationImage(data) expected = SegmentationImage(data).slices segm.relabel_consecutive() assert segm.slices == expected segm.remove_label(1) assert segm.slices == [slc for slc in find_objects(segm.data) if slc is not None] astropy-photutils-24be529/photutils/segmentation/tests/test_deblend.py000066400000000000000000002364731526452531300265270ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the deblend module. """ import warnings 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 scipy import ndimage as ndi from photutils.segmentation import SegmentationImage from photutils.segmentation import deblend as deblend_module from photutils.segmentation import deblend_sources, detect_sources from photutils.segmentation._deblend_markers import (deblend_markers_chunk, deblend_source_stats) from photutils.segmentation._deblend_reference import _SingleSourceDeblender from photutils.segmentation._deblend_watershed import (apply_relabel_map, deblend_contrast_chunk, deblend_watershed, find_present_labels, write_deblended_labels) from photutils.segmentation.deblend import (_ChunkResult, _compute_thresholds, _create_relabel_map, _DeblendParams, _relabel_consecutive) from photutils.segmentation.flags import SEGMENTATION_FLAGS from photutils.segmentation.utils import _make_binary_structure from photutils.utils.exceptions import (DeblendWarning, PhotutilsDeprecationWarning) 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) assert result.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) 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) 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) assert result.n_labels == 2 @pytest.mark.parametrize(('contrast', 'n_labels'), [(0.001, 6), (0.017, 5), (0.06, 4), (0.1, 3), (0.15, 2), (0.45, 1)]) def test_deblend_contrast(self, contrast, n_labels): """ 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) assert segm2.n_labels == n_labels 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) 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) 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) 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) 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) 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) assert result.n_labels == 2 assert_equal(result.labels, [2, 3]) assert_equal(result.parent_to_deblended_labels, {1: [2, 3]}) assert (result.parent_to_deblended_labels[1].dtype == result.data.dtype) 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) 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) 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) @pytest.mark.parametrize('relabel', [False, True]) def test_contrast_one_relabel(self, relabel): """ Test that contrast=1 (no deblending) honors the relabel keyword for nonconsecutive input labels. """ segm = self.segm.copy() segm.reassign_label(1, 1000) result = deblend_sources(self.data, segm, self.n_pixels, contrast=1, relabel=relabel) expected = [1] if relabel else [1000] assert_equal(result.labels, expected) def test_contrast_one_fresh_image(self): """ Test that contrast=1 (no deblending) returns an image without the info, flags, and deblended labels of an input image that was itself deblended. """ data = self.data - 20 match = 'The deblending mode of one or more source labels from the' with pytest.warns(DeblendWarning, match=match): segm = deblend_sources(data, self.segm, self.n_pixels) assert segm.info assert np.any(segm.flags) assert len(segm.deblended_labels) > 0 result = deblend_sources(data, segm, self.n_pixels, contrast=1) assert_equal(result.data, segm.data) assert result.info == {} assert not np.any(result.flags) assert len(result.deblended_labels) == 0 def test_contrast_one_invalid_labels(self): """ Test that contrast=1 (no deblending) validates the labels keyword. """ match = 'is invalid' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.n_pixels, contrast=1, labels=1000) def test_empty_segmentation_image(self): """ Test that a segmentation image with no nonzero labels raises a ValueError. """ segm = SegmentationImage(np.zeros(self.data.shape, dtype=int)) match = 'segmentation_image must have at least one nonzero label' with pytest.raises(ValueError, match=match): deblend_sources(self.data, segm, self.n_pixels) @pytest.mark.parametrize('n_pixels', [0, -5, 2.5]) def test_invalid_n_pixels(self, n_pixels): """ Test that invalid n_pixels values raise a ValueError. """ match = 'n_pixels must be a positive integer' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, n_pixels) @pytest.mark.parametrize('n_levels', [0, -3, 2.7]) def test_invalid_n_levels(self, n_levels): """ Test that invalid n_levels values raise a ValueError. """ match = 'n_levels must be a positive integer' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.n_pixels, n_levels=n_levels) 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) 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') 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') 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) 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(DeblendWarning, match=match): segm = deblend_sources(data, self.segm, self.n_pixels) assert list(segm.info) == ['non_positive_min_labels'] assert_equal(segm.info['non_positive_min_labels'], [1]) def test_flags_deblended(self): """ Test that deblended children carry the deblended flag and nothing else when no mode fallback occurred. """ result = deblend_sources(self.data, self.segm, self.n_pixels) assert_equal(result.flags, np.full(result.n_labels, SEGMENTATION_FLAGS.DEBLENDED)) def test_flags_nonposmin_children(self): """ Test that children of a parent whose mode fell back due to nonpositive minimum data values carry both the deblended and fallback flags. """ data = self.data.copy() data -= 20 match = 'The deblending mode of one or more source labels' with pytest.warns(DeblendWarning, match=match): segm = deblend_sources(data, self.segm, self.n_pixels) expected = (SEGMENTATION_FLAGS.DEBLENDED | SEGMENTATION_FLAGS.DEBLEND_NON_POSITIVE_MIN) for label in segm.parent_to_deblended_labels[1]: idx = segm.get_index(label) assert segm.flags[idx] == expected def test_flags_detect_sources_zero(self): """ Test that detect_sources output has all-zero flags. """ assert_equal(self.segm.flags, np.zeros(self.segm.n_labels, dtype=int)) 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(DeblendWarning, match=match): segm = deblend_sources(data, self.segm, self.n_pixels) assert_equal(segm.info['non_positive_min_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) 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) 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) assert segm2.n_labels == 2 # The NaN pixel is assigned to the source surrounding it assert segm2.data[50, 50] == segm2.data[50, 51] != 0 @pytest.mark.parametrize('n_threads', [1, 2]) def test_data_nan_basin_contrast(self, n_threads): """ Test that a NaN pixel does not change which sources pass the basin contrast criterion. The NaN pixel is excluded from the flux of the source it is assigned to, so only the faint source that is below the contrast is removed. """ kwargs = {'contrast': 0.2, 'contrast_method': 'basin', 'n_threads': n_threads} result = deblend_sources(self.data3, self.segm3, self.n_pixels, **kwargs) assert result.n_labels == 2 # A NaN pixel at the peak of one of the bright sources data = self.data3.copy() data[50, 50] = np.nan result_nan = deblend_sources(data, self.segm3, self.n_pixels, **kwargs) assert_equal(result_nan.data, result.data) def test_watershed(self): """ Test deblending a segment whose label exceeds 255. The watershed input mask must be a bool array rather than the label array, otherwise a label such as 512 breaks the watershed. """ segm = self.segm.copy() segm.reassign_label(1, 512) result = deblend_sources(self.data, segm, self.n_pixels) 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) 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) 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_per_level() assert len(markers) == 19 def test_info_empty_without_warnings(self): """ Test that the returned segmentation image always has an info attribute, which is an empty dict when no deblending warnings occurred. """ result = deblend_sources(self.data, self.segm, self.n_pixels) assert result.info == {} # detect_sources output must also have an info attribute assert self.segm.info == {} @pytest.mark.parametrize('kwargs', [{'progress_bar': False}, {'n_processes': 2}, {'nproc': 2}]) def test_deprecated_keywords(self, kwargs): """ Test that the progress_bar, n_processes, and nproc keywords each emit a single deprecation warning and have no effect on the results. """ name = next(iter(kwargs)) with pytest.warns(PhotutilsDeprecationWarning, match=name) as record: result = deblend_sources(self.data, self.segm, self.n_pixels, mode='linear', **kwargs) assert len(record) == 1 assert result.n_labels == 2 def make_marker_test_image(kind): """ Return an image containing a single connected source for the marker-path equivalence tests. Parameters ---------- kind : {'blend', 'quantized', 'plateau'} The image type. 'blend' is a Gaussian envelope with compact peaks spanning a wide amplitude range, 'quantized' is the same image coarsely quantized (duplicate data values produce empty multithreshold levels), and 'plateau' contains flat stepped square annuli with two embedded peaks. Returns ------- data : 2D `~numpy.ndarray` The image. """ if kind == 'plateau': data = np.zeros((101, 101)) data[10:90, 10:90] = 1.0 data[30:70, 30:70] = 2.0 data[35:45, 35:45] = 5.0 data[55:65, 55:65] = 4.0 return data rng = np.random.default_rng(0) y, x = np.mgrid[0:151, 0:151] data = Gaussian2D(20, 75, 75, 40, 40)(x, y) amplitudes = np.geomspace(3.0, 100.0, 12) radii = 40 * np.sqrt(rng.uniform(0.0, 1.0, 12)) angles = rng.uniform(0.0, 2.0 * np.pi, 12) for amplitude, radius, angle in zip(amplitudes, radii, angles, strict=True): xc = 75 + radius * np.cos(angle) yc = 75 + radius * np.sin(angle) data += Gaussian2D(amplitude, xc, yc, 2.5, 2.5)(x, y) if kind == 'quantized': data = np.round(data * 2.0) / 2.0 return data def normalize_markers(markers): """ Relabel a marker image with consecutive raster-ordered labels. Parameters ---------- markers : 2D int `~numpy.ndarray` The marker image. Returns ------- result : 2D int `~numpy.ndarray` The relabeled marker image. """ relabel_map = _create_relabel_map(markers) if relabel_map is not None: markers = relabel_map[markers] return markers @pytest.mark.parametrize('kind', ['blend', 'quantized', 'plateau']) @pytest.mark.parametrize('mode', ['exponential', 'linear', 'sinh']) @pytest.mark.parametrize('connectivity', [8, 4]) def test_make_markers_matches_legacy(kind, mode, connectivity): """ Test that make_markers produces the same markers as the legacy per-level path (the last image of the make_markers_per_level chain). The markers must contain the same regions with the same raster-scan label ordering, since the ordering determines the final deblended label assignment. """ data = make_marker_test_image(kind) segm = detect_sources(data, 0.5, 5, connectivity=connectivity) footprint = _make_binary_structure(2, connectivity) n_seen = 0 for label, slc in zip(segm.labels, segm.slices, strict=True): params = _DeblendParams(5, footprint, 32, 0.001, mode) deblender = _SingleSourceDeblender(data[slc], segm.data[slc], label, params) markers = deblender.make_markers() params = _DeblendParams(5, footprint, 32, 0.001, mode) deblender = _SingleSourceDeblender(data[slc], segm.data[slc], label, params) legacy = deblender.make_markers_per_level() legacy = None if legacy is None else legacy[-1] if markers is None or legacy is None: assert markers is None assert legacy is None else: n_seen += 1 assert_equal(normalize_markers(markers), normalize_markers(legacy)) assert n_seen >= 1 class ReferenceHeap: """ Binary min-heap on (value, age) that mirrors the compiled kernel. The heap compares only the value and the age, so ties between markers with equal image values (which all enter with age 0) are resolved by the heap layout. The reference must reproduce that layout to match the kernel pixel for pixel. """ def __init__(self): self.items = [] def less(self, a, b): """ Compare two heap slots by (value, age). """ value_a, age_a, _ = self.items[a] value_b, age_b, _ = self.items[b] if value_a != value_b: return value_a < value_b return age_a < age_b def push(self, value, age, index): """ Push an item onto the heap. """ items = self.items items.append((value, age, index)) pos = len(items) - 1 while pos > 0: parent = (pos - 1) // 2 if not self.less(pos, parent): break items[pos], items[parent] = items[parent], items[pos] pos = parent def pop(self): """ Pop the smallest item. """ items = self.items result = items[0] last = items.pop() n_items = len(items) if n_items > 0: items[0] = last pos = 0 while True: child = 2 * pos + 1 if child >= n_items: break if child + 1 < n_items and self.less(child + 1, child): child += 1 if not self.less(child, pos): break items[pos], items[child] = items[child], items[pos] pos = child return result def reference_watershed(image, markers, mask, connectivity): """ Compute the marker-based watershed with a pure-Python flood. This is the priority-flood watershed (Soille 1990) with the same neighbor ordering, plateau cost, and queue-age tie-breaking as the compiled kernel. It was validated pixel for pixel against ``skimage.segmentation.watershed`` (scikit-image 0.26) over 1200 randomized trials before scikit-image was dropped from the test dependencies. Parameters ---------- image : 2D `~numpy.ndarray` The image to flood. markers : 2D int `~numpy.ndarray` The marker image. Zero means not a marker. mask : 2D bool `~numpy.ndarray` Only pixels where the mask is `True` are labeled. connectivity : {8, 4} The pixel connectivity. Returns ------- output : 2D int `~numpy.ndarray` The labeled basins. """ ny, nx = image.shape # Orthogonal neighbors before diagonal ones, each in raster order. offsets = [(-1, 0), (0, -1), (0, 1), (1, 0)] if connectivity == 8: offsets += [(-1, -1), (-1, 1), (1, -1), (1, 1)] output = markers.astype(np.int32, copy=True) heap = ReferenceHeap() for index in np.flatnonzero((output != 0) & mask): heap.push(float(image.flat[index]), 0, int(index)) age = 0 while heap.items: pop_value, _, index = heap.pop() y0, x0 = divmod(index, nx) for dy, dx in offsets: y = y0 + dy x = x0 + dx if y < 0 or y >= ny or x < 0 or x >= nx: continue if not mask[y, x] or output[y, x] != 0: continue age += 1 output[y, x] = output[y0, x0] push_value = max(float(image[y, x]), pop_value) heap.push(push_value, age, y * nx + x) return output @pytest.mark.parametrize('connectivity', [8, 4]) def test_watershed_matches_reference(connectivity): """ Test that the deblending watershed kernel produces results identical to the pure-Python reference flood over randomized images, including integer-valued and constant images whose plateaus exercise the queue-age tie-breaking. """ footprint = _make_binary_structure(2, connectivity) rng = np.random.default_rng(987) n_run = 0 for trial in range(150): ny, nx = rng.integers(5, 35, 2) kind = trial % 4 if kind == 0: image = rng.normal(0.0, 1.0, (ny, nx)) elif kind == 1: image = rng.integers(0, 4, (ny, nx)).astype(float) elif kind == 2: image = np.zeros((ny, nx)) else: image = np.round(rng.normal(0.0, 1.0, (ny, nx)), 1) mask = rng.random((ny, nx)) < 0.8 indices = np.flatnonzero(mask) if indices.size < 4: continue seeds = np.zeros((ny, nx), dtype=bool) pick = rng.choice(indices, size=min(6, indices.size), replace=False) seeds.ravel()[pick] = True markers = ndi.label(seeds, structure=footprint)[0].astype(np.int32) expected = reference_watershed(image, markers, mask, connectivity) result = deblend_watershed(image, markers, mask, connectivity) assert_equal(result, expected) n_run += 1 assert n_run > 100 def python_deblend_chunk(data, segm_data, driver_data, # noqa: ARG001 driver_segm, # noqa: ARG001 labels, slices, deblend_params): """ Deblend a chunk of sources with the pure-Python reference path. This mirrors the compiled chunk driver used by deblend_sources, computing the markers and fallbacks per source in Python. Parameters ---------- data, segm_data, driver_data, driver_segm : 2D `~numpy.ndarray` The (driver-compatible) data and segmentation arrays. labels : 1D `~numpy.ndarray` The labels of the sources in the chunk. slices : list of tuple of slice The bounding-box slices of the sources in the chunk. deblend_params : `_DeblendParams` The parameters for deblending the sources. Returns ------- result : `_ChunkResult` The per-source deblended labels in the packed layout of the compiled chunk driver. """ y0 = np.array([slc[0].start for slc in slices], dtype=np.int64) y1 = np.array([slc[0].stop for slc in slices], dtype=np.int64) x0 = np.array([slc[1].start for slc in slices], dtype=np.int64) x1 = np.array([slc[1].stop for slc in slices], dtype=np.int64) sizes = (y1 - y0) * (x1 - x0) offsets = np.zeros(len(labels) + 1, dtype=np.intp) np.cumsum(sizes, out=offsets[1:]) packed = np.zeros(offsets[-1], dtype=np.int32) n_labels = np.zeros(len(labels), dtype=np.intp) nonposmin = np.zeros(len(labels), dtype=bool) n_markers_fallback = np.zeros(len(labels), dtype=bool) for index, (label, slc) in enumerate(zip(labels, slices, strict=True)): deblender = _SingleSourceDeblender(data[slc], segm_data[slc], label, deblend_params) deblended = deblender.deblend_source() nonposmin[index] = 'nonposmin' in deblender.warnings n_markers_fallback[index] = 'n_markers' in deblender.warnings if deblended is not None: n_labels[index] = deblended.max() packed[offsets[index]:offsets[index + 1]] = deblended.ravel() return _ChunkResult(n_labels=n_labels, packed=packed, offsets=offsets, y0=y0, y1=y1, x0=x0, x1=x1, nonposmin=nonposmin, n_markers_fallback=n_markers_fallback) @pytest.mark.parametrize('contrast_method', ['basin', 'saddle']) @pytest.mark.parametrize('dtype', ['float64', 'float32', '>f4', 'int32']) @pytest.mark.parametrize('scene', ['blend', 'negmin', 'checkerboard', 'gaussian', 'flat', 'contrast-batch', 'contrast-single', 'contrast-all', 'contrast-negmin', 'neighbors', 'nan', 'checkerboard-linear', 'checkerboard-negmin']) def test_chunk_driver_matches_python_path(dtype, scene, contrast_method): """ Test that the compiled chunk driver and contrast loop produce results identical to the pure-Python per-source path, including the threshold computation, the mode fallbacks, the below-contrast marker removal, and the recorded warnings, for float64, float32, and integer data. The scenes cover deblending sources, the non-positive-minimum and too-many-markers mode fallbacks, a source that does not split, a constant source, and contrast values that trigger the batched removal, the one-at-a-time removal, the removal of all but one basin, and the removal path for sources with a negative minimum (which always removes one marker at a time), a scene of several segments with overlapping bounding boxes, NaN pixels within a segment, the checkerboard deblended in linear mode, which keeps all of its markers instead of falling back, and the checkerboard with a nonpositive minimum, which falls back to sinh and is then retried with linear levels, each with both contrast criteria. """ contrast = 0.001 mode = 'linear' if scene == 'checkerboard-linear' else 'exponential' if scene == 'blend': data = make_marker_test_image('blend') threshold, n_pixels = 0.5, 5 elif scene == 'negmin': data = make_marker_test_image('blend') - 15.0 threshold, n_pixels = -14.5, 5 elif scene.startswith('contrast'): data, _ = make_multipeak_source() threshold, n_pixels = 0.5, 5 contrast = {'contrast-batch': 0.15, 'contrast-single': 0.07, 'contrast-all': 0.35, 'contrast-negmin': 0.15}[scene] if scene == 'contrast-negmin': data = data - 5.0 threshold = -4.5 elif scene == 'gaussian': y, x = np.mgrid[0:51, 0:51] data = Gaussian2D(10, 25, 25, 5, 5)(x, y) threshold, n_pixels = 0.5, 5 elif scene == 'flat': data = np.zeros((51, 51)) data[20:40, 20:40] = 5.0 threshold, n_pixels = 0.5, 5 elif scene == 'neighbors': # Blended pairs whose bounding boxes contain other segments y, x = np.mgrid[0:121, 0:161] data = (Gaussian2D(100, 50, 60, 6, 6)(x, y) + Gaussian2D(90, 68, 60, 6, 6)(x, y) + Gaussian2D(60, 82, 40, 3, 3)(x, y) + Gaussian2D(80, 120, 70, 5, 5)(x, y) + Gaussian2D(70, 135, 70, 5, 5)(x, y) + Gaussian2D(50, 118, 95, 3, 3)(x, y)) threshold, n_pixels = 5.0, 5 contrast = 0.01 elif scene == 'nan': if not np.issubdtype(np.dtype(dtype), np.floating): pytest.skip('NaN requires a floating-point dtype') data = make_marker_test_image('blend') threshold, n_pixels = 0.5, 5 else: # The n_markers fallback checkerboard scene size = 51 data1 = np.resize([0, 0, 1, 1], size) data1 = np.abs(data1 - np.atleast_2d(data1).T) + 2.0 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 threshold, n_pixels = 0.01, 1 data = data.astype(dtype) with warnings.catch_warnings(): # The negmin scenes use a negative threshold on purpose. warnings.filterwarnings('ignore', 'threshold is negative', AstropyUserWarning) segm = detect_sources(data, threshold, n_pixels) if scene == 'checkerboard-negmin': # A nonpositive minimum (sinh fallback) combined with too many # markers (linear retry), applied after the detection data = data - 2 if scene == 'nan': # NaN pixels within the segment, set after the detection rng = np.random.default_rng(11) ys, xs = np.nonzero(segm.data > 0) pick = rng.choice(ys.size, size=40, replace=False) data[ys[pick], xs[pick]] = np.nan with warnings.catch_warnings(): warnings.simplefilter('ignore', DeblendWarning) result = deblend_sources(data, segm, n_pixels, contrast=contrast, mode=mode, contrast_method=contrast_method) with patch.object(deblend_module, '_deblend_sources_chunk', python_deblend_chunk): expected = deblend_sources(data, segm, n_pixels, contrast=contrast, mode=mode, contrast_method=contrast_method) assert_equal(result.data, expected.data) assert result.info.keys() == expected.info.keys() for key in expected.info: assert_equal(result.info[key], expected.info[key]) assert result._flags_map == expected._flags_map def test_n_threads_identical(): """ Test that multithreaded deblending produces results identical to the single-threaded computation, including the mode-fallback warning, info, and flags, and when there are more threads than sources. """ y, x = np.mgrid[0:101, 0:301] data = (Gaussian2D(100, 50, 50, 5, 5)(x, y) + Gaussian2D(100, 35, 50, 5, 5)(x, y) + Gaussian2D(80, 150, 50, 5, 5)(x, y) + Gaussian2D(60, 165, 50, 5, 5)(x, y) + Gaussian2D(50, 250, 50, 5, 5)(x, y)) segm = detect_sources(data, 10, 5) assert segm.n_labels == 3 data -= 20 # nonpositive minima trigger the mode fallback match = 'The deblending mode of one or more source labels' with pytest.warns(DeblendWarning, match=match): expected = deblend_sources(data, segm, 5) assert expected.n_labels == 5 assert_equal(expected.info['non_positive_min_labels'], [1, 2, 3]) for n_threads in (2, 3, 64): with pytest.warns(DeblendWarning, match=match) as record: result = deblend_sources(data, segm, 5, n_threads=n_threads) assert len(record) == 1 assert_equal(result.data, expected.data) assert result._flags_map == expected._flags_map assert_equal(result.parent_to_deblended_labels, expected.parent_to_deblended_labels) assert result.info.keys() == expected.info.keys() for key in expected.info: assert_equal(result.info[key], expected.info[key]) def test_n_threads_worker_error(): """ Test that an error raised while deblending a chunk in a worker thread propagates to the caller. """ tile = np.zeros((51, 51)) tile[15:36, 15:36] = 10.0 tile[13, 13] = 10.0 tile[14, 14] = 5.0 tile[36, 36] = 10.0 tile[37, 37] = 10.0 data = np.zeros((51, 120)) data[:, :51] = tile data[:, 60:111] = tile segm = detect_sources(data, 0.1, 1, connectivity=8) assert segm.n_labels == 2 match = 'Deblending failed for source' with pytest.raises(ValueError, match=match): deblend_sources(data, segm, 1, mode='linear', connectivity=4, n_threads=2) @pytest.mark.parametrize('n_threads', [0, -1, 1.5, True]) def test_n_threads_invalid(n_threads): """ Test that invalid n_threads values raise a ValueError. """ data, segm = make_multipeak_source() match = 'n_threads must be a positive integer' with pytest.raises(ValueError, match=match): deblend_sources(data, segm, 5, n_threads=n_threads) def test_deblend_segm_dtype(): """ Test that deblending a segmentation image with a nonnative integer dtype gives the same result as the int32 one. """ data, segm = make_multipeak_source() expected = deblend_sources(data, segm, 5) segm16 = SegmentationImage(segm.data.astype(np.int16)) result = deblend_sources(data, segm16, 5) assert_equal(result.data, expected.data) @pytest.mark.parametrize('dtype', [np.uint16, np.uint32, np.uint64]) def test_deblend_segm_unsigned_dtype(dtype): """ Test that deblending a segmentation image with an unsigned integer dtype gives the same result as the int32 one and keeps the input dtype. """ data, segm = make_multipeak_source() expected = deblend_sources(data, segm, 5) segm_unsigned = SegmentationImage(segm.data.astype(dtype)) result = deblend_sources(data, segm_unsigned, 5) assert result.data.dtype == dtype assert_equal(result.data, expected.data) expected_map = expected._deblend_label_map assert result._deblend_label_map.keys() == expected_map.keys() for label, children in result._deblend_label_map.items(): assert children.dtype == dtype assert_equal(children, expected_map[label]) assert result._flags_map == expected._flags_map def test_deblend_byte_order(): """ Test that nonnative byte order data and segmentation images give results identical to the native ones. """ data, segm = make_multipeak_source() for dtype in ('f4', 'f8'): expected = deblend_sources(data.astype(f'<{dtype}'), segm, 5, contrast=0.01) result = deblend_sources(data.astype(f'>{dtype}'), segm, 5, contrast=0.01) assert_equal(result.data, expected.data) expected = deblend_sources(data, segm, 5, contrast=0.01) segm_be = SegmentationImage(segm.data.astype('>i4')) result = deblend_sources(data, segm_be, 5, contrast=0.01) assert_equal(result.data, expected.data) @pytest.mark.parametrize('dtype', ['float64', 'float32', 'int32']) @pytest.mark.parametrize('mode', ['exponential', 'linear', 'sinh']) @pytest.mark.parametrize('n_levels', [1, 7, 32]) def test_compute_thresholds_matches_reference(dtype, mode, n_levels): """ Test that the vectorized multithreshold levels are bitwise identical to the per-source levels of the reference implementation, including the exponential-mode fallback for nonpositive minima and the zero-step special case of np.linspace. """ rng = np.random.default_rng(42) n_src = 60 if dtype == 'int32': smin = rng.integers(-50, 50, n_src) smax = smin + rng.integers(1, 1000, n_src) else: smin = rng.uniform(-5.0, 5.0, n_src) smax = smin + 10.0 ** rng.uniform(-3.0, 3.0, n_src) # The subnormal range exercises the zero-step case. It is # skipped on platforms where array arithmetic flushes subnormals # to zero (e.g., float32 with ARMv7 NEON), which makes the range # degenerate. subnormal = np.full(16, np.finfo(dtype).smallest_subnormal, dtype=dtype) if np.all(subnormal - np.zeros(16, dtype=dtype) > 0): smin[1] = 0.0 smax[1] = subnormal[0] smin[0] = 0 smin = smin.astype(dtype) smax = smax.astype(dtype) assert np.all(smax > smin) thresholds, nonposmin = _compute_thresholds(smin, smax, n_levels, mode) assert thresholds.shape == (n_src, n_levels) assert thresholds.dtype == np.float64 assert thresholds.flags.c_contiguous params = _DeblendParams(1, np.ones((3, 3)), n_levels, 0.001, mode) segment = np.ones((1, 2), dtype=int) for i in range(n_src): data = np.array([[smin[i], smax[i]]], dtype=dtype) deblender = _SingleSourceDeblender(data, segment, 1, params) expected = np.asarray(deblender.compute_thresholds(), dtype=np.float64) # Compare the bit patterns, not just the values assert_equal(thresholds[i].view(np.int64), expected.view(np.int64)) assert nonposmin[i] == ('nonposmin' in deblender.warnings) @pytest.mark.parametrize('dtype', ['float64', 'float32', 'int32']) def test_source_stats_matches_reference(dtype): """ Test that the compiled per-source minimum, maximum, and flux are identical to the reference implementation, with NaN pixels excluded, and that the flux agrees with np.nansum. """ data, segm = make_multipeak_source() data = data.astype(dtype) if dtype != 'int32': data[45:48, 40:43] = np.nan driver_data = np.ascontiguousarray(data, dtype=np.float64) labels = np.asarray(segm.labels, dtype=np.int64) slc = segm.slices[0] y0 = np.array([slc[0].start], dtype=np.int64) y1 = np.array([slc[0].stop], dtype=np.int64) x0 = np.array([slc[1].start], dtype=np.int64) x1 = np.array([slc[1].stop], dtype=np.int64) smin, smax, ssum = deblend_source_stats(driver_data, segm.data, labels, y0, y1, x0, x1) params = _DeblendParams(5, np.ones((3, 3)), 32, 0.001, 'linear') deblender = _SingleSourceDeblender(data[slc], segm.data[slc], 1, params) assert smin[0] == deblender.source_min assert smax[0] == deblender.source_max assert ssum[0] == deblender.source_sum values = data[segm.data == 1] assert_allclose(ssum[0], np.nansum(values, dtype=np.float64), rtol=1e-12) def make_packed_pair(): """ Return a two-source scene in the packed chunk layout. The first source is a blended pair that splits into two markers and the second is a single Gaussian that does not split. Returns ------- data : 2D `~numpy.ndarray` The image. segm : `~photutils.segmentation.SegmentationImage` The segmentation image. bounds : tuple of 1D int64 `~numpy.ndarray` The ``(labels, y0, y1, x0, x1)`` arrays of the two sources. offsets : 1D intp `~numpy.ndarray` The packed region offsets, with one more entry than sources. """ y, x = np.mgrid[0:61, 0:141] data = (Gaussian2D(100, 30, 30, 5, 5)(x, y) + Gaussian2D(100, 45, 30, 5, 5)(x, y) + Gaussian2D(50, 110, 30, 5, 5)(x, y)) segm = detect_sources(data, 10, 5) assert segm.n_labels == 2 labels = np.asarray(segm.labels, dtype=np.int64) y0 = np.array([slc[0].start for slc in segm.slices], dtype=np.int64) y1 = np.array([slc[0].stop for slc in segm.slices], dtype=np.int64) x0 = np.array([slc[1].start for slc in segm.slices], dtype=np.int64) x1 = np.array([slc[1].stop for slc in segm.slices], dtype=np.int64) sizes = (y1 - y0) * (x1 - x0) offsets = np.concatenate(([0], np.cumsum(sizes))).astype(np.intp) return data, segm, (labels, y0, y1, x0, x1), offsets def run_packed_markers(data, segm, bounds, offsets, *, max_markers=-1): """ Build the markers of a packed two-source scene. Returns ------- packed : 1D int32 `~numpy.ndarray` The packed marker buffer. n_markers : 1D intp `~numpy.ndarray` The marker count of each source. stats : tuple of 1D float64 `~numpy.ndarray` The ``(source_min, source_max, source_sum)`` arrays. """ labels, y0, y1, x0, x1 = bounds stats = deblend_source_stats(data, segm.data, labels, y0, y1, x0, x1) thresholds, _ = _compute_thresholds(stats[0], stats[1], 32, 'exponential') packed = np.zeros(offsets[-1], dtype=np.int32) n_markers = deblend_markers_chunk(data, segm.data, labels, y0, y1, x0, x1, thresholds, packed, offsets[:-1], n_pixels=5, connectivity=8, max_markers=max_markers) return packed, n_markers, stats @pytest.mark.parametrize('contrast', [0.001, 0.6]) def test_contrast_chunk_packed_buffer(contrast): """ Test that the chunk contrast kernel relabels the packed region of a source that deblends with consecutive labels covering its segment, zeros the region of a source that collapses to one basin, and skips the sources with fewer than two markers. """ data, segm, bounds, offsets = make_packed_pair() labels, y0, y1, x0, x1 = bounds packed, n_markers, (smin, _, ssum) = run_packed_markers( data, segm, bounds, offsets) assert_equal(n_markers, [2, 0]) packed[offsets[1]:offsets[2]] = 7 # skipped regions are untouched n_labels = deblend_contrast_chunk(data, segm.data, labels, y0, y1, x0, x1, packed, offsets[:-1], n_markers, connectivity=8, contrast=contrast, source_sum=ssum, source_min=smin, apply_contrast=True) region0 = packed[offsets[0]:offsets[1]] segment0 = (segm.data[y0[0]:y1[0], x0[0]:x1[0]] == labels[0]).ravel() if contrast == 0.6: # Both basins hold about half the flux, so the fainter one is # removed and the source is left with a single basin assert_equal(n_labels, [1, 0]) assert not region0.any() else: assert_equal(n_labels, [2, 0]) assert_equal(np.unique(region0), [0, 1, 2]) assert_equal(region0 > 0, segment0) assert_equal(packed[offsets[1]:offsets[2]], 7) @pytest.mark.parametrize('dtype', [np.int32, np.int64]) def test_write_deblended_labels(dtype): """ Test that the write-out kernel adds the per-source offset to the nonzero packed labels of the sources with two or more labels and leaves every other pixel and source untouched. """ segm_out = np.full((4, 6), 9, dtype=dtype) y0 = np.array([0, 2], dtype=np.int64) y1 = np.array([2, 4], dtype=np.int64) x0 = np.array([1, 3], dtype=np.int64) x1 = np.array([4, 6], dtype=np.int64) region0 = np.array([[1, 0, 2], [1, 2, 0]], dtype=np.int32) region1 = np.array([[1, 1, 1], [0, 0, 1]], dtype=np.int32) packed = np.concatenate((region0.ravel(), region1.ravel())) starts = np.array([0, region0.size], dtype=np.intp) n_labels = np.array([2, 1], dtype=np.intp) label_offsets = np.array([10, 20], dtype=np.int64) write_deblended_labels(segm_out, packed, starts, y0, y1, x0, x1, n_labels, label_offsets) expected = np.full((4, 6), 9, dtype=dtype) expected[0, 1] = 11 expected[0, 3] = 12 expected[1, 1] = 11 expected[1, 2] = 12 assert_equal(segm_out, expected) assert segm_out.dtype == dtype @pytest.mark.parametrize('dtype', [np.int32, np.int64]) def test_relabel_kernels(dtype): """ Test that the relabel kernels mark the labels that are present and rewrite the array in place through the relabeling map. """ segm = np.array([[0, 5, 5, 0], [9, 0, 2, 2], [9, 9, 0, 5]], dtype=dtype) present = np.zeros(11, dtype=np.uint8) present[7] = 1 # the elements of absent labels are left unchanged find_present_labels(segm, present) assert_equal(np.flatnonzero(present), [0, 2, 5, 7, 9]) relabel_map = np.zeros(11, dtype=np.int64) relabel_map[[2, 5, 9]] = [1, 2, 3] expected = relabel_map[segm] apply_relabel_map(segm, relabel_map) assert_equal(segm, expected) assert segm.dtype == dtype # empty arrays are accepted empty = np.zeros((0, 4), dtype=dtype) find_present_labels(empty, present) apply_relabel_map(empty, relabel_map) @pytest.mark.parametrize('dtype', [np.int32, np.int64]) @pytest.mark.parametrize('value', [-1, 4]) def test_relabel_kernels_invalid_values(dtype, value): """ Test that the relabel kernels raise an error for a label that cannot index the per-label array. """ segm = np.array([[0, 1], [value, 3]], dtype=dtype) match = 'segm contains a value outside the range of present' with pytest.raises(ValueError, match=match): find_present_labels(segm, np.zeros(4, dtype=np.uint8)) match = 'present must not be empty' with pytest.raises(ValueError, match=match): find_present_labels(segm, np.zeros(0, dtype=np.uint8)) match = 'segm contains a value outside the range of relabel_map' with pytest.raises(ValueError, match=match): apply_relabel_map(segm, np.arange(4, dtype=np.int64)) match = 'relabel_map must not be empty' with pytest.raises(ValueError, match=match): apply_relabel_map(segm, np.zeros(0, dtype=np.int64)) @pytest.mark.parametrize('dtype', [np.int32, np.int64]) @pytest.mark.parametrize('n_threads', [1, 2, 3, 8, 50]) def test_relabel_consecutive(dtype, n_threads): """ Test that the in-place consecutive relabeling matches the NumPy relabeling map for any number of threads, including more threads than image rows. """ rng = np.random.default_rng(0) labels = np.array([0, 0, 0, 3, 4, 17, 250, 251, 999]) segm = rng.choice(labels, size=(31, 40)).astype(dtype) expected_map = _create_relabel_map(segm) expected = expected_map[segm] relabel_map = _relabel_consecutive(segm, 1200, n_threads) assert_equal(segm, expected) assert segm.dtype == dtype assert_equal(relabel_map[:1000], expected_map) assert_equal(relabel_map[1000:], 0) # Consecutive labels are left unchanged original = segm.copy() assert _relabel_consecutive(segm, 1200, n_threads) is None assert_equal(segm, original) # An array with no labels is left unchanged zeros = np.zeros((5, 4), dtype=dtype) assert _relabel_consecutive(zeros, 10, n_threads) is None assert_equal(zeros, 0) @pytest.mark.parametrize('n_threads', [1, 4]) def test_relabel_nonconsecutive_input(n_threads): """ Test that deblending relabels a segmentation image with nonconsecutive input labels identically to relabeling the nonrelabeled result. """ data, segm = make_multipeak_source() segm_data = segm.data.copy() segm_data[segm_data > 0] = 1000 segm_data[0:3, 0:3] = 40 segm = SegmentationImage(segm_data) raw = deblend_sources(data, segm, 5, relabel=False, n_threads=n_threads) result = deblend_sources(data, segm, 5, n_threads=n_threads) relabel_map = _create_relabel_map(raw.data) assert_equal(result.data, relabel_map[raw.data]) assert result.is_consecutive assert result.data.dtype == segm_data.dtype result_map = result.parent_to_deblended_labels for parent, children in raw.parent_to_deblended_labels.items(): assert_equal(result_map[parent], relabel_map[children]) @pytest.mark.parametrize('n_threads', [1, 4]) def test_relabel_large_labels(n_threads, monkeypatch): """ Test that an image with labels too large for the compiled relabeling is relabeled with the sort-based method, giving the same result as the same image with small labels. """ data, segm = make_multipeak_source() expected = deblend_sources(data, segm, 5, n_threads=n_threads) offset = 70000 # above the limit for an image of this size segm_data = np.where(segm.data > 0, segm.data + offset, 0) segm_large = SegmentationImage(segm_data) def fail(*args, **kwargs): # noqa: ARG001 msg = 'the compiled relabeling must not be used' raise AssertionError(msg) monkeypatch.setattr(deblend_module, '_relabel_consecutive', fail) result = deblend_sources(data, segm_large, 5, n_threads=n_threads) assert_equal(result.data, expected.data) assert result.data.dtype == segm_data.dtype children = result.parent_to_deblended_labels[1 + offset] assert_equal(children, result.labels) assert children.dtype == segm_data.dtype raw = deblend_sources(data, segm_large, 5, relabel=False, n_threads=n_threads) assert_equal(raw.labels, offset + 1 + np.arange(1, raw.n_labels + 1)) def test_chunk_kernels_validate_inputs(): """ Test that the chunk kernels reject per-source arrays with the wrong length and a packed buffer that is too small for the regions. """ data, segm, bounds, offsets = make_packed_pair() labels, y0, y1, x0, x1 = bounds packed, n_markers, (smin, smax, ssum) = run_packed_markers( data, segm, bounds, offsets) thresholds, _ = _compute_thresholds(smin, smax, 32, 'exponential') starts = offsets[:-1] short = starts[:1] small = packed[:-1] n_labels = np.array([2, 0], dtype=np.intp) label_offsets = np.zeros(2, dtype=np.int64) segm_out = segm.data.copy() match_len = 'one entry per source' match_size = 'packed is too small' with pytest.raises(ValueError, match=match_len): deblend_markers_chunk(data, segm.data, labels, y0, y1, x0, x1, thresholds, packed, short, n_pixels=5, connectivity=8, max_markers=-1) with pytest.raises(ValueError, match=match_size): deblend_markers_chunk(data, segm.data, labels, y0, y1, x0, x1, thresholds, small, starts, n_pixels=5, connectivity=8, max_markers=-1) with pytest.raises(ValueError, match=match_len): deblend_contrast_chunk(data, segm.data, labels, y0, y1, x0, x1, packed, short, n_markers, connectivity=8, contrast=0.001, source_sum=ssum, source_min=smin, apply_contrast=True) with pytest.raises(ValueError, match=match_size): deblend_contrast_chunk(data, segm.data, labels, y0, y1, x0, x1, small, starts, n_markers, connectivity=8, contrast=0.001, source_sum=ssum, source_min=smin, apply_contrast=True) with pytest.raises(ValueError, match=match_len): write_deblended_labels(segm_out, packed, short, y0, y1, x0, x1, n_labels, label_offsets) with pytest.raises(ValueError, match=match_size): write_deblended_labels(segm_out, small, starts, y0, y1, x0, x1, n_labels, label_offsets) def test_source_stats_all_nan(): """ Test that a segment without any finite pixel has NaN extrema and zero flux in both the compiled stats kernel and the reference implementation, and that deblend_sources leaves it unchanged. """ data = np.full((3, 4), np.nan) segment = np.ones((3, 4), dtype=np.int32) labels = np.array([1], dtype=np.int64) smin, smax, ssum = deblend_source_stats(data, segment, labels, np.array([0], dtype=np.int64), np.array([3], dtype=np.int64), np.array([0], dtype=np.int64), np.array([4], dtype=np.int64)) assert np.isnan(smin[0]) assert np.isnan(smax[0]) assert ssum[0] == 0.0 params = _DeblendParams(1, np.ones((3, 3)), 32, 0.001, 'linear') with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) deblender = _SingleSourceDeblender(data, segment, 1, params) assert np.isnan(deblender.source_min) assert np.isnan(deblender.source_max) assert deblender.source_sum == 0.0 segm = SegmentationImage(segment) result = deblend_sources(data, segm, 1) assert_equal(result.data, segment) assert result.parent_to_deblended_labels == {} def test_markers_chunk_packed_buffer(): """ Test that the marker kernel writes each source's markers into its packed region, leaves the regions of sources that do not split or that exceed max_markers at zero, and reports the marker counts. """ data, segm, bounds, offsets = make_packed_pair() packed, n_markers, _ = run_packed_markers(data, segm, bounds, offsets) assert_equal(n_markers, [2, 0]) region0 = packed[offsets[0]:offsets[1]] region1 = packed[offsets[1]:offsets[2]] assert_equal(np.unique(region0), [0, 1, 2]) assert not region1.any() # A limit below the marker count leaves the region zero but still # reports the count packed, n_markers, _ = run_packed_markers(data, segm, bounds, offsets, max_markers=1) assert_equal(n_markers, [2, 0]) assert not packed.any() @pytest.mark.parametrize('dtype', ['float64', 'float32']) @pytest.mark.parametrize('connectivity', [8, 4]) @pytest.mark.parametrize( ('scene', 'contrast'), [('multipeak', 0.0), ('multipeak', 0.001), ('multipeak', 0.01), ('multipeak', 0.1), ('hierarchical', 0.001), ('hierarchical', 0.05), ('negmin', 0.01), ('discrete', 0.01), ('discrete', 0.03), ('discrete', 0.05), ('quantized', 0.001), ('quantized', 0.01)]) def test_saddle_markers_match_reference(dtype, connectivity, scene, contrast): """ Test that the compiled saddle-criterion marker selection matches the per-level reference implementation, including nested splits, dissolving below-contrast siblings, the sinh fallback for negative minima, both connectivities, float32 data, and data with empty threshold levels (discrete values and coarse quantization), where a branch must be evaluated at the level above its junction rather than at the top of its unchanged range of levels. """ y, x = np.mgrid[0:101, 0:101] mode = 'exponential' if scene == 'hierarchical': data = (Gaussian2D(1.0, 50, 50, 30, 30)(x, y) + Gaussian2D(100, 35, 50, 3, 3)(x, y) + Gaussian2D(80, 45, 50, 3, 3)(x, y) + Gaussian2D(60, 70, 50, 3, 3)(x, y)) elif scene == 'discrete': # A pedestal with two stepped blocks. Most of the linear levels # between the steps are empty data = np.zeros((41, 41)) data[2:39, 2:39] = 1.0 for cy, cx in ((12, 12), (28, 28)): data[cy - 2:cy + 3, cx - 2:cx + 3] = 6.0 data[cy - 1:cy + 2, cx - 1:cx + 2] = 10.0 mode = 'linear' elif scene == 'quantized': data = make_marker_test_image('quantized') else: data, _ = make_multipeak_source() threshold = 0.5 if scene == 'negmin': data = data - 5.0 threshold = -4.5 data = data.astype(dtype) footprint = _make_binary_structure(2, connectivity) with warnings.catch_warnings(): # The negmin scene uses a negative threshold on purpose warnings.filterwarnings('ignore', 'threshold is negative', AstropyUserWarning) segm = detect_sources(data, threshold, 5, connectivity=connectivity) slc = segm.slices[0] cutout = data[slc] params = _DeblendParams(5, footprint, 32, contrast, mode, 'saddle') deblender = _SingleSourceDeblender(cutout, segm.data[slc], 1, params) expected = deblender.make_markers() thresholds = deblender.compute_thresholds() y0 = np.array([slc[0].start], dtype=np.int64) y1 = np.array([slc[0].stop], dtype=np.int64) x0 = np.array([slc[1].start], dtype=np.int64) x1 = np.array([slc[1].stop], dtype=np.int64) thresholds_2d = np.ascontiguousarray(thresholds[None, :], dtype=np.float64) limit = deblender.contrast * float(deblender.source_sum) packed = np.zeros(cutout.size, dtype=np.int32) starts = np.zeros(1, dtype=np.intp) n_markers = deblend_markers_chunk( np.ascontiguousarray(data), segm.data, np.array([1], dtype=np.int64), y0, y1, x0, x1, thresholds_2d, packed, starts, n_pixels=5, connectivity=connectivity, max_markers=-1, saddle_limits=np.array([limit], dtype=np.float64)) result = None if n_markers[0] >= 2: result = packed.reshape(cutout.shape) if expected is None or result is None: assert expected is None assert result is None else: assert_equal(result, expected) def test_contrast_method_invalid(): """ Test that an invalid contrast_method raises a ValueError. """ data, segm = make_multipeak_source() match = 'contrast_method must be None, .basin., or .saddle.' with pytest.raises(ValueError, match=match): deblend_sources(data, segm, 5, contrast_method='invalid') def test_contrast_method_default(): """ Test that the default contrast_method of None currently resolves to the 'basin' method. """ data, segm = make_multipeak_source() result_default = deblend_sources(data, segm, 5, contrast=0.1) result_basin = deblend_sources(data, segm, 5, contrast=0.1, contrast_method='basin') assert_equal(result_default.data, result_basin.data) def test_saddle_deblend(): """ Test deblending with the saddle contrast criterion through the public API, including a source that does not split, the parent label map, the deblended flags, and thread-count invariance. The label counts are regression pins for the multipeak scene (its watershed basin flux fractions are about 0.061, 0.062, 0.225, and 0.652). The results are also compared with the pure-Python reference path. """ y, x = np.mgrid[0:101, 0:181] data, _ = make_multipeak_source() image = np.zeros((101, 181)) image[:, :101] = data image += Gaussian2D(10, 150, 50, 4, 4)(x, y) # lone source segm = detect_sources(image, 0.5, 5) assert segm.n_labels == 2 result = deblend_sources(image, segm, 5, contrast=0.001, contrast_method='saddle') assert result.n_labels == 5 assert_equal(result.parent_to_deblended_labels, {1: [2, 3, 4, 5]}) assert np.count_nonzero(result.flags & SEGMENTATION_FLAGS.DEBLENDED) == 4 # Higher contrast drops the faint basins entirely (they cannot # combine and survive, as there is no removal iteration). result2 = deblend_sources(image, segm, 5, contrast=0.1, contrast_method='saddle') assert result2.n_labels == 3 result3 = deblend_sources(image, segm, 5, contrast=0.001, contrast_method='saddle', n_threads=4) assert_equal(result3.data, result.data) with patch.object(deblend_module, '_deblend_sources_chunk', python_deblend_chunk): expected = deblend_sources(image, segm, 5, contrast=0.001, contrast_method='saddle') expected2 = deblend_sources(image, segm, 5, contrast=0.1, contrast_method='saddle') assert_equal(result.data, expected.data) assert_equal(result2.data, expected2.data) def test_nonposmin_fallback_sinh(): """ Test that the non-positive-minimum fallback uses sinh mode. The sinh spacing keeps the threshold levels concentrated near the source minimum, recovering a faint companion of a bright source that the linear spacing misses. """ y, x = np.mgrid[0:61, 0:81] data = (Gaussian2D(1000, 36, 30, 1.7, 1.7)(x, y) + Gaussian2D(20, 44, 30, 1.7, 1.7)(x, y) - 1.2) with warnings.catch_warnings(): # The negative threshold is the point of the test warnings.filterwarnings('ignore', 'threshold is negative', AstropyUserWarning) segm = detect_sources(data, -0.2, 5) assert segm.n_labels == 1 match = 'The deblending mode of one or more source labels' with pytest.warns(DeblendWarning, match=match): result = deblend_sources(data, segm, 5, mode='exponential', contrast=1e-6) assert result.n_labels == 2 assert_equal(result.info['non_positive_min_labels'], [1]) bit = SEGMENTATION_FLAGS.DEBLEND_NON_POSITIVE_MIN assert_equal(result.flags & bit, [bit, bit]) # The linear mode does not recover this companion, so the sinh # fallback is a real sensitivity improvement over the previous # linear fallback. result_linear = deblend_sources(data, segm, 5, mode='linear', contrast=1e-6) assert result_linear.n_labels == 1 def test_python_path_connectivity_mismatch(): """ Test that the pure-Python reference path raises the same error as the compiled contrast loop when the detection and deblending connectivities differ. """ 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=8) match = 'Deblending failed for source' with (patch.object(deblend_module, '_deblend_sources_chunk', python_deblend_chunk), pytest.raises(ValueError, match=match)): deblend_sources(data, segm, 1, mode='linear', connectivity=4) def make_multipeak_source(): """ Return the image and segmentation image for a single connected source with peaks spanning a wide range of basin fluxes. The watershed basin flux fractions are approximately 0.061, 0.062, 0.225, and 0.652, so the contrast keyword controls how many of the faintest basins fail the contrast criterion. Returns ------- data : 2D `~numpy.ndarray` The image. segm : `~photutils.segmentation.SegmentationImage` The segmentation image containing a single label. """ y, x = np.mgrid[0:101, 0:101] envelope = Gaussian2D(1.0, 50, 50, 30, 30) g_bright = Gaussian2D(100, 40, 50, 3, 3) g_medium = Gaussian2D(30, 68, 50, 3, 3) g_faint1 = Gaussian2D(3.0, 50, 30, 3, 3) g_faint2 = Gaussian2D(4.0, 50, 72, 3, 3) data = (envelope(x, y) + g_bright(x, y) + g_medium(x, y) + g_faint1(x, y) + g_faint2(x, y)) segm = detect_sources(data, 0.5, 5) return data, segm @pytest.mark.parametrize(('contrast', 'n_labels'), [(0.0, 4), (0.07, 2), (0.15, 2), (0.35, 1)]) def test_contrast_removal(contrast, n_labels): """ Test the below-contrast marker removal over a range of contrasts. The contrast=0.15 case removes the two faintest basins together (their total flux fraction is below both the contrast and the next-faintest basin flux), the contrast=0.07 case removes the same two basins one at a time (their total is above the contrast), and the contrast=0.35 case removes all but one basin so that no deblending occurs. """ data, segm = make_multipeak_source() result = deblend_sources(data, segm, 5, contrast=contrast) assert result.n_labels == n_labels assert_equal(np.nonzero(segm.data), np.nonzero(result.data)) if n_labels > 1: assert_equal(result.parent_to_deblended_labels, {1: list(range(1, n_labels + 1))}) else: assert_equal(result.parent_to_deblended_labels, {}) def test_n_markers_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(DeblendWarning, match=match): segm2 = deblend_sources(data, segm, 1, mode='exponential') assert segm2.info['too_many_markers_labels'][0] == 1 def test_flags_n_markers_fallback(): """ Test that the n_markers fallback flag is set on the output sources produced from the affected input label. """ 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' with pytest.warns(DeblendWarning, match=match): segm2 = deblend_sources(data, segm, 1, mode='exponential') bit = SEGMENTATION_FLAGS.DEBLEND_TOO_MANY_MARKERS flagged = segm2.labels[(segm2.flags & bit) > 0] assert len(flagged) > 0 # Every flagged label traces back to input label 1 if 1 in segm2.parent_to_deblended_labels: assert_equal(np.sort(flagged), np.sort(segm2.parent_to_deblended_labels[1])) else: assert_equal(flagged, [1]) @pytest.mark.parametrize('relabel', [True, False]) def test_flags_fallback_without_deblending(relabel): """ Test that a fallback parent that did not split still gets the fallback flag on its output label, with and without relabeling. """ # Three well-separated, nondeblendable sources with a negative # minimum (to trigger the nonposmin fallback). The middle label is # removed after detection so the remaining input labels (1 and 3) # are nonconsecutive, exercising the relabel-map translation path # when relabel=True. g1 = Gaussian2D(100, 25, 25, 3, 3) g2 = Gaussian2D(100, 50, 50, 3, 3) g3 = Gaussian2D(100, 75, 75, 3, 3) yy, xx = np.mgrid[0:101, 0:101] data = g1(xx, yy) + g2(xx, yy) + g3(xx, yy) - 20.0 segm = detect_sources(data + 20.0, 10, 5) assert_equal(segm.labels, [1, 2, 3]) segm.remove_label(2, relabel=False) assert_equal(segm.labels, [1, 3]) match = 'The deblending mode of one or more source labels' with pytest.warns(DeblendWarning, match=match): segm2 = deblend_sources(data, segm, 5, relabel=relabel) bit = SEGMENTATION_FLAGS.DEBLEND_NON_POSITIVE_MIN # Both remaining sources fell back (both have negative minima). # Neither splits, so each output label carries the fallback bit # but not the deblended bit assert_equal(segm2.flags & bit, [bit] * segm2.n_labels) assert_equal(segm2.flags & SEGMENTATION_FLAGS.DEBLENDED, [0] * segm2.n_labels) def test_nonposmin_astropy_user_warning(): """ Test that the nonposmin warning is caught as an AstropyUserWarning, checking that DeblendWarning is a subclass of it so that existing warning filters continue to work. """ 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) assert 'non_positive_min_labels' in segm2.info assert np.all(segm2.flags & SEGMENTATION_FLAGS.DEBLEND_NON_POSITIVE_MIN) def test_n_markers_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(): 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 @pytest.mark.parametrize('dtype', [np.float32, np.float64]) @pytest.mark.parametrize('contrast_method', ['basin', 'saddle']) @pytest.mark.parametrize('n_threads', [1, 2]) def test_read_only_inputs(dtype, contrast_method, n_threads): """ Regression test that read-only (nonwritable) input arrays are accepted, are not modified, and give results identical to writable arrays. """ data, segm = make_multipeak_source() data = data.astype(dtype) segm_data = segm.data.copy() arrays = (data, segm_data) originals = [arr.copy() for arr in arrays] def compute(): return deblend_sources(data, SegmentationImage(segm_data), 5, contrast=0.01, contrast_method=contrast_method, n_threads=n_threads).data expected = compute() assert expected.max() > 1 for arr in arrays: arr.setflags(write=False) result = compute() assert_equal(result, expected) for arr, original in zip(arrays, originals, strict=True): assert_equal(arr, original) @pytest.mark.parametrize('relabel', [True, False]) def test_huge_labels(relabel): """ Regression test that an image with huge label values is deblended with memory set by the number of labels, giving the same segments as the same image with ordinary labels. """ data, segm = make_multipeak_source() offset = 2**40 huge = np.where(segm.data > 0, segm.data.astype(np.int64) + offset, 0) segm_huge = SegmentationImage(huge) expected = deblend_sources(data, segm, 5, relabel=relabel) result = deblend_sources(data, segm_huge, 5, relabel=relabel) assert result.n_labels == expected.n_labels > 1 assert_equal(result.areas, expected.areas) assert result.slices == expected.slices if relabel: assert_equal(result.data, expected.data) else: assert_equal(result.labels, expected.labels.astype(np.int64) + offset) children = result.parent_to_deblended_labels[1 + offset] assert_equal(children, result.labels) assert_equal(result.flags, expected.flags) @pytest.mark.parametrize('dtype', ['>i4', '>i8', '>u8']) @pytest.mark.parametrize('offset', [0, 10**6]) @pytest.mark.parametrize('relabel', [True, False]) def test_non_native_byte_order(dtype, offset, relabel): """ Test that a segmentation image with a nonnative byte order, such as one read from a FITS file, is deblended to an image with the same dtype, for small and large labels. """ data, segm = make_multipeak_source() dtype = np.dtype(dtype) segm_data = np.where(segm.data > 0, segm.data.astype(np.int64) + offset, 0).astype(dtype) expected = deblend_sources(data, segm, 5, relabel=relabel) result = deblend_sources(data, SegmentationImage(segm_data), 5, relabel=relabel) assert result.data.dtype == dtype assert result.data.dtype.byteorder == dtype.byteorder assert result.labels.dtype.byteorder == dtype.byteorder assert result.n_labels == expected.n_labels > 1 assert_equal(result.areas, expected.areas) assert result.slices == expected.slices astropy-photutils-24be529/photutils/segmentation/tests/test_detect.py000066400000000000000000000431411526452531300263660ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the detect module. """ import warnings import astropy.units as u import numpy as np import pytest from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning 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_zero_dimensional_background_error(self): """ Test that 0-d array background and error values are treated as scalars. """ ref = detect_threshold(DATA, n_sigma=2.0, background=1.0, error=0.5) threshold = detect_threshold(DATA, n_sigma=2.0, background=np.array(1.0), error=np.array(0.5)) assert_allclose(threshold, ref) 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_sigma_clip_none(self): """ Test that sigma_clip=None turns off the sigma clipping. """ rng = np.random.default_rng(0) data = rng.normal(5.0, 1.0, (50, 50)) data[10, 10] = 1000.0 threshold = detect_threshold(data, n_sigma=2.0, sigma_clip=None) assert_allclose(threshold, np.mean(data) + 2.0 * np.std(data)) clipped = detect_threshold(data, n_sigma=2.0) assert np.all(clipped < threshold) # Masked pixels are still excluded mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True threshold = detect_threshold(data, n_sigma=2.0, mask=mask, sigma_clip=None) expected = np.mean(data[~mask]) + 2.0 * np.std(data[~mask]) assert_allclose(threshold, expected) @pytest.mark.parametrize('value', [np.nan, np.inf, -np.inf]) def test_sigma_clip_none_nonfinite(self, value): """ Test that sigma_clip=None warns about the unmasked nonfinite values and excludes them from the statistics. """ rng = np.random.default_rng(0) data = rng.normal(5.0, 1.0, (50, 50)) data[10, 10] = value good = np.isfinite(data) expected = np.mean(data[good]) + 2.0 * np.std(data[good]) match = 'Input data contains nonfinite values' with pytest.warns(AstropyUserWarning, match=match): threshold = detect_threshold(data, n_sigma=2.0, sigma_clip=None) assert_allclose(threshold, expected) # No warning is emitted if the nonfinite value is masked threshold = detect_threshold(data, n_sigma=2.0, mask=~good, sigma_clip=None) assert_allclose(threshold, expected) 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) def test_detect_sources_seeds_areas(): """ Test that detect_sources returns a SegmentationImage with the areas already cached and correct. """ data = np.zeros((10, 10)) data[2:5, 2:5] = 10.0 data[6:8, 6:9] = 10.0 segm = detect_sources(data, 1.0, 4) assert 'areas' in segm.__dict__ expected = np.unique(segm.data[segm.data != 0], return_counts=True)[1] assert_equal(segm.areas, expected) def test_detect_sources_seeded_areas_after_relabel(): """ Test that the seeded areas stay aligned with the labels when the n_pixels cut removes sources and the remaining ones are relabeled. """ data = np.zeros((20, 20)) data[1:2, 1:2] = 10.0 # 1 pixel, removed data[4:7, 4:7] = 10.0 # 9 pixels, kept data[9:10, 9:11] = 10.0 # 2 pixels, removed data[12:16, 12:17] = 10.0 # 20 pixels, kept segm = detect_sources(data, 1.0, 4) assert_equal(segm.labels, [1, 2]) assert_equal(segm.areas, [9, 20]) expected = np.unique(segm.data[segm.data != 0], return_counts=True)[1] assert_equal(segm.areas, expected) 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) @pytest.mark.parametrize('threshold', [-1.0, -1e-6]) def test_negative_scalar_threshold(self, threshold): """ Test that a negative scalar threshold emits a warning and that the detection still proceeds. """ match = f'threshold is negative \\({threshold}\\)' with pytest.warns(AstropyUserWarning, match=match): segm = detect_sources(self.data, threshold=threshold, n_pixels=2) assert segm is not None assert segm.n_labels == 1 def test_negative_quantity_threshold(self): """ Test that the warning is emitted for a Quantity threshold. """ match = 'threshold is negative' with pytest.warns(AstropyUserWarning, match=match): detect_sources(self.data << u.uJy, threshold=-1.0 * u.uJy, n_pixels=2) threshold = np.full(self.data.shape, 0.9) << u.uJy threshold[0, 0] = -1.0 * u.uJy match = r'threshold has 1 negative value\(s\)' with pytest.warns(AstropyUserWarning, match=match): detect_sources(self.data << u.uJy, threshold=threshold, n_pixels=2) def test_negative_array_threshold(self): """ Test that a threshold array with negative values emits a warning that counts only the negative values and that the detection still proceeds. """ threshold = np.full(self.data.shape, 0.9) threshold[0, 0] = 0.0 threshold[2, 2] = -1.0 match = r'threshold has 1 negative value\(s\)' with pytest.warns(AstropyUserWarning, match=match): segm = detect_sources(self.data, threshold=threshold, n_pixels=2) # The zero-valued pixel above the negative threshold joins the # source through its corner expected = self.refdata.copy() expected[2, 2] = 1 assert_equal(segm.data, expected) def test_nonnegative_threshold_no_warning(self): """ Test that zero and positive thresholds, including an array with zero and NaN values, do not emit the warning. Pixels must be strictly greater than the threshold, so a zero threshold cannot include pixels with zero or negative values. """ threshold = np.full(self.data.shape, 0.9) threshold[0, 0] = np.nan threshold[1, 1] = 0.0 with warnings.catch_warnings(): warnings.simplefilter('error') segm = detect_sources(self.data, threshold=threshold, n_pixels=2) assert_equal(segm.data, self.refdata) segm = detect_sources(self.data, threshold=0.0, n_pixels=2) assert_equal(segm.data, self.refdata) segm = detect_sources(self.data, threshold=1e-6, n_pixels=2) assert_equal(segm.data, self.refdata) 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_nan_data(self): """ Test that NaN pixels are excluded from sources and that the threshold comparison with NaN values does not emit warnings. """ data = np.ones((5, 5)) data[2, 2] = np.nan segm = detect_sources(data, threshold=0.5, n_pixels=5) assert segm.n_labels == 1 expected = np.ones((5, 5), dtype=np.int32) expected[2, 2] = 0 assert_equal(segm.data, expected) 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-24be529/photutils/segmentation/tests/test_finder.py000066400000000000000000000134051526452531300263650ustar00rootroot00000000000000# 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 numpy.testing import assert_equal from photutils.datasets import make_100gaussians_image from photutils.segmentation.finder import SourceFinder from photutils.segmentation.flags import SEGMENTATION_FLAGS from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils.exceptions import (NoDetectionsWarning, PhotutilsDeprecationWarning) 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 def test_deblend(self): """ Test deblend. """ finder = SourceFinder(n_pixels=self.n_pixels) 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) 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) 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) match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): segm = finder(self.convolved_data, 1000) assert segm is None 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) cls_repr = repr(finder) assert cls_repr.startswith(finder.__class__.__name__) def test_finder_deprecations(): finder = SourceFinder(n_pixels=10) match = 'attribute was deprecated' with pytest.warns(PhotutilsDeprecationWarning, match=match): _ = finder.npixels with pytest.warns(PhotutilsDeprecationWarning, match=match): _ = finder.nlevels # Each deprecated keyword emits exactly one warning for kwargs in ({'progress_bar': False}, {'n_processes': 2}, {'nproc': 2}): name = next(iter(kwargs)) with pytest.warns(PhotutilsDeprecationWarning, match=name) as record: SourceFinder(n_pixels=10, **kwargs) assert len(record) == 1 def test_finder_n_threads(): """ Test that SourceFinder n_threads produces results identical to the single-threaded computation. """ yy, xx = np.mgrid[0:101, 0:101] data = (Gaussian2D(100, 50, 50, 5, 5)(xx, yy) + Gaussian2D(100, 35, 50, 5, 5)(xx, yy)) expected = SourceFinder(n_pixels=5)(data, 10) finder = SourceFinder(n_pixels=5, n_threads=4) assert 'n_threads=4' in repr(finder) segm = finder(data, 10) assert_equal(segm.data, expected.data) @pytest.mark.parametrize(('kwargs', 'match'), [ ({'connectivity': 3}, 'Invalid connectivity'), ({'n_levels': 0}, 'n_levels must be a positive integer'), ({'n_levels': 2.5}, 'n_levels must be a positive integer'), ({'contrast': 1.5}, 'contrast must be'), ({'contrast_method': 'invalid'}, 'contrast_method must be'), ({'mode': 'invalid'}, 'mode must be'), ({'n_threads': 0}, 'n_threads must be a positive integer'), ({'n_threads': True}, 'n_threads must be a positive integer'), ]) def test_finder_invalid_kwargs(kwargs, match): """ Test that invalid keyword values are rejected at construction. """ with pytest.raises(ValueError, match=match): SourceFinder(n_pixels=5, **kwargs) @pytest.mark.parametrize('name', ['deblend', 'relabel']) def test_finder_invalid_bool_kwargs(name): """ Test that the boolean keywords must be booleans. """ match = f"{name} must be a boolean, got 'yes'" with pytest.raises(TypeError, match=match): SourceFinder(n_pixels=5, **{name: 'yes'}) assert SourceFinder(n_pixels=5, **{name: np.True_}) is not None def test_finder_flags_passthrough(): """ Test that deblending provenance flags survive the SourceFinder pipeline. """ yy, xx = np.mgrid[0:101, 0:101] data = (Gaussian2D(100, 50, 50, 5, 5)(xx, yy) + Gaussian2D(100, 35, 50, 5, 5)(xx, yy)) finder = SourceFinder(n_pixels=5) segm = finder(data, 10) assert np.any(segm.flags & SEGMENTATION_FLAGS.DEBLENDED) astropy-photutils-24be529/photutils/segmentation/tests/test_flags.py000066400000000000000000000074571526452531300262240ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the flags module. """ import numpy as np import pytest from photutils.aperture.flags import APERTURE_FLAGS from photutils.segmentation.flags import (SEGMENTATION_FLAGS, decode_segmentation_flags) # Flags that intentionally share a name (and meaning, with the segment # as the region) with APERTURE_FLAGS SHARED_APERTURE_NAMES = ('masked_pixels', 'non_finite_data', 'non_finite_error', 'all_masked', 'undefined_shape', 'singular_covariance') EXPECTED_BITS = { 'masked_pixels': 1, 'non_finite_data': 2, 'non_finite_error': 4, 'all_masked': 8, 'edge_touch': 16, 'deblended': 32, 'deblend_non_positive_min': 64, 'deblend_too_many_markers': 128, 'undefined_shape': 256, 'singular_covariance': 512, 'kron_undefined': 1024, 'kron_minimum_radius': 2048, 'kron_no_overlap': 4096, 'kron_partial_overlap': 8192, 'kron_masked_pixels': 16384, 'kron_non_finite_data': 32768, 'kron_non_finite_error': 65536, 'kron_neighbor_pixels': 131072, 'kron_uncorrected_pixels': 262144, 'local_bkg_too_few_pixels': 524288, } def test_flag_names_and_bits(): """ Test the frozen flag names and bit values. """ assert set(SEGMENTATION_FLAGS.names) == set(EXPECTED_BITS) for name, bit in EXPECTED_BITS.items(): assert SEGMENTATION_FLAGS.get_bit_value(name) == bit def test_bit_values_unique_powers_of_two(): """ Test that all bit values are unique powers of two. """ bits = SEGMENTATION_FLAGS.bit_values assert len(bits) == len(set(bits)) for bit in bits: assert bit > 0 assert bit & (bit - 1) == 0 def test_shared_aperture_names_intentional(): """ Test that the name overlap with APERTURE_FLAGS is exactly the intentional shared set. A shared name must describe the same condition (with the segment as the region). Any new accidental overlap must either be renamed or added to the shared set deliberately. """ shared = (set(SEGMENTATION_FLAGS.names) & set(APERTURE_FLAGS.names)) assert shared == set(SHARED_APERTURE_NAMES) def test_uppercase_constants(): """ Test that uppercase constants exist for every flag. """ for name, bit in EXPECTED_BITS.items(): assert getattr(SEGMENTATION_FLAGS, name.upper()) == bit def test_decode_scalar(): """ Test decoding a scalar flag value. """ value = (SEGMENTATION_FLAGS.DEBLENDED | SEGMENTATION_FLAGS.EDGE_TOUCH) names = decode_segmentation_flags(value) assert set(names) == {'deblended', 'edge_touch'} bits = decode_segmentation_flags(value, return_bit_values=True) assert set(bits) == {16, 32} assert decode_segmentation_flags(0) == [] def test_decode_array(): """ Test decoding an array of flag values. """ values = np.array([0, SEGMENTATION_FLAGS.MASKED_PIXELS]) result = decode_segmentation_flags(values) assert result == [[], ['masked_pixels']] def test_decode_invalid(): """ Test invalid decoder inputs. """ match = 'must be an integer' with pytest.raises(TypeError, match=match): decode_segmentation_flags(1.5) match = 'must be a nonnegative integer' with pytest.raises(ValueError, match=match): decode_segmentation_flags(-1) def test_public_import(): """ Test that the public names are importable from the subpackage. """ import photutils.segmentation as seg_mod # noqa: PLC0415 assert hasattr(seg_mod, 'SEGMENTATION_FLAGS') assert hasattr(seg_mod, 'decode_segmentation_flags') assert seg_mod.SEGMENTATION_FLAGS is SEGMENTATION_FLAGS assert seg_mod.decode_segmentation_flags is decode_segmentation_flags astropy-photutils-24be529/photutils/segmentation/tests/test_positional_kwargs.py000066400000000000000000000077541526452531300306670ustar00rootroot00000000000000# 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 photutils.segmentation.core import SegmentationImage from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils.exceptions import PhotutilsDeprecationWarning 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/segmentation/tests/test_utils.py000066400000000000000000000134001526452531300262510ustar00rootroot00000000000000# 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 def test_mask_to_mirrored_value_return_uncorrected(): """ Test that return_uncorrected reports pixels whose mirror was outside the image. """ center = (2.0, 2.0) data = np.arange(16.0).reshape(4, 4) replace_mask = np.zeros(data.shape, dtype=bool) replace_mask[0, 0] = True # mirror at (4, 4) is outside the image replace_mask[1, 2] = True # mirror at (3, 2) is available result, uncorrected = _mask_to_mirrored_value( data, replace_mask, center, return_uncorrected=True) assert uncorrected[0, 0] assert not uncorrected[1, 2] assert np.count_nonzero(uncorrected) == 1 assert result[0, 0] == 0 assert result[1, 2] == data[3, 2] # The two-value return preserves the corrected data result_only = _mask_to_mirrored_value(data, replace_mask, center) assert_equal(result, result_only) def test_mask_to_mirrored_value_return_uncorrected_masked(): """ Test that return_uncorrected reports pixels whose mirror is excluded by the ``mask`` keyword or is itself in ``replace_mask``. """ 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 # mirror at (4, 2) is in mask replace_mask[1, 1] = True # mirror at (3, 3) is in replace_mask replace_mask[3, 3] = True mask[4, 2] = True result, uncorrected = _mask_to_mirrored_value( data, replace_mask, center, mask=mask, return_uncorrected=True) assert_equal(np.nonzero(uncorrected), (np.array([0, 1, 3]), np.array([2, 1, 3]))) assert result[0, 2] == 0 assert result[1, 1] == 0 assert result[3, 3] == 0 astropy-photutils-24be529/photutils/segmentation/utils.py000066400000000000000000000164301526452531300240560ustar00rootroot00000000000000# 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, in pixels. 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. 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, return_uncorrected=False): """ 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. Note that this function is not used in production. The `~photutils.segmentation.SourceCatalog` batch kernels apply the mirroring correction in compiled code. It is kept as the pure-Python mirror of that correction and must track the compiled semantics exactly, which is enforced by the batch cross-implementation tests. 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 float The (x, y) center coordinates around which masked pixels will be mirrored. mask : 2D bool `~numpy.ndarray`, optional 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 nonfinite or bad pixel values. return_uncorrected : bool, optional If `True`, also return a boolean mask of the ``replace_mask`` pixels that could not be replaced by a mirrored value (i.e., the mirror pixel was outside the image, in ``mask``, or itself in ``replace_mask``) and were set to zero instead. Returns ------- result : 2D `~numpy.ndarray` A 2D array with replaced masked pixels. If ``return_uncorrected`` is `True`, then a tuple of ``(result, uncorrected)`` is returned instead, where ``uncorrected`` is a 2D bool `~numpy.ndarray` that is `True` for the ``replace_mask`` pixels that were set to zero because their mirrored value was unavailable. """ outdata = np.copy(data) uncorrected = (np.zeros(data.shape, dtype=bool) if return_uncorrected else None) 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): youtside = ymasked[badmask] xoutside = xmasked[badmask] outdata[youtside, xoutside] = 0.0 if return_uncorrected: uncorrected[youtside, xoutside] = True # 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 if return_uncorrected: uncorrected[ybad, xbad] = True return outdata, uncorrected return outdata astropy-photutils-24be529/photutils/tests/000077500000000000000000000000001526452531300210055ustar00rootroot00000000000000astropy-photutils-24be529/photutils/tests/__init__.py000066400000000000000000000000001526452531300231040ustar00rootroot00000000000000astropy-photutils-24be529/photutils/tests/test_import.py000066400000000000000000000013351526452531300237320ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the import of the top-level package. """ import subprocess # nosec B404 import sys import pytest @pytest.mark.parametrize('module', ['photutils.utils', 'astropy.table', 'scipy']) def test_import_is_lightweight(module): """ Test that importing the top-level package does not import its subpackages or their heavy dependencies. """ code = f'import sys; import photutils; print({module!r} in sys.modules)' result = subprocess.run( # noqa: S603 # nosec B603 [sys.executable, '-c', code], check=True, capture_output=True, text=True) assert result.stdout.strip() == 'False' astropy-photutils-24be529/photutils/utils/000077500000000000000000000000001526452531300210035ustar00rootroot00000000000000astropy-photutils-24be529/photutils/utils/__init__.py000066400000000000000000000010241526452531300231110ustar00rootroot00000000000000# 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 from .wcs import * # noqa: F401, F403 astropy-photutils-24be529/photutils/utils/_convolution.py000066400000000000000000000057521526452531300241040ustar00rootroot00000000000000# 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 : 2D `~numpy.ndarray` or `~astropy.units.Quantity` The 2D array of the image. kernel : 2D array_like, `~astropy.convolution.Kernel2D`, or `None` 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. If `None`, ``data`` is returned unchanged. 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 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 and a `~numpy.ndarray` otherwise. """ 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-24be529/photutils/utils/_coords.py000066400000000000000000000101751526452531300230110ustar00rootroot00000000000000# 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. For each group of coordinates separated by ``min_separation`` or less, the coordinate that appears first in ``xycoords`` is kept and its neighbors 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 ``(M, 2)``, where ``M <= N``, after removing the points within the minimum separation of a kept point. """ 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) n_coords = size if min_separation > 0: # Scale the number of random coordinates to account for # some being discarded due to min_separation n_coords *= oversample rng = np.random.default_rng(seed) xc = rng.uniform(x_range[0], x_range[1], n_coords) yc = rng.uniform(y_range[0], y_range[1], n_coords) 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-24be529/photutils/utils/_deprecation.py000066400000000000000000000616571526452531300240300ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for deprecating functions, arguments, attributes, and table column names. The table tools 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 copy import deepcopy 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 photutils._future_column_names import get_future_column_names from photutils.utils.exceptions import PhotutilsDeprecationWarning 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, warning_type=PhotutilsDeprecationWarning) 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, warning_type=PhotutilsDeprecationWarning) 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, warning_type=PhotutilsDeprecationWarning) 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, PhotutilsDeprecationWarning, 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, PhotutilsDeprecationWarning, 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 because instances are created directly from a concrete subclass (``DeprecatedColumnTable`` or ``DeprecatedColumnQTable``) in which this mixin precedes the Astropy table class in the 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}', you may use the context manager " '``use_future_column_names()`` (recommended) to opt into a ' 'standard QTable without the deprecated column name mapping.') warnings.warn(msg, PhotutilsDeprecationWarning, 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 ------- result : str or list The translated new column name(s). A list is returned for both list and tuple input. Any other nonstring input is returned unchanged. """ 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 : str or list or tuple The name(s) of the column(s) 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 : str or list or tuple The name(s) of the column(s) 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 The name of the column 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 def __deepcopy__(self, memo): out = self.copy(copy_data=False) for name in out.colnames: out.columns.__setitem__(name, deepcopy(self[name]), validated=True) out.meta = deepcopy(self.meta) return out 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 future column names are enabled (via ``photutils.future_column_names`` or the `~photutils.use_future_column_names` context manager), 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 future column names are enabled, 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 future column names are enabled (via ``photutils.future_column_names`` or the `~photutils.use_future_column_names` context manager), 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 future column names are enabled) is created. The default is `False`. **kwargs : dict, optional Any other keywords accepted by the ``astropy.table.Table`` constructor (e.g., ``masked=True``, ``meta={...}``). Returns ------- table : `DeprecatedColumnTable`, `DeprecatedColumnQTable`, \ `~astropy.table.Table`, or `~astropy.table.QTable` A new table instance with deprecation behavior. If future column names are enabled, 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-24be529/photutils/utils/_flags.py000066400000000000000000000317011526452531300226120ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Generic machinery for defining and decoding bitwise quality flags. This private module provides the shared building blocks used by the public per-package flag registries (e.g., ``photutils.psf.flags`` and ``photutils.aperture.flags``): a flag-definition dataclass, a registry base class, a flag decoder, and a docstring-substitution helper. """ import re import warnings from dataclasses import dataclass from typing import ClassVar import numpy as np from photutils.utils._deprecation import deprecated_getattr from photutils.utils.exceptions import PhotutilsDeprecationWarning __all__ = [] @dataclass(frozen=True) class FlagDefinition: """ A single bitwise flag definition. Attributes ---------- bit_value : int The bit value (power of 2) for this flag. name : str Short name for the flag (used by the flag decoders). 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 FlagRegistry: """ Base class for centralized definitions of bitwise quality flags. Subclasses define the ``FLAG_DEFINITIONS`` list of `FlagDefinition` objects and a short ``domain`` string identifying the flag source (e.g., ``'psf'`` or ``'aperture'``). The registry provides a single source of truth for the flag bit values, names, and descriptions, enabling consistent flag handling and dynamic docstring generation. Uppercase constants (e.g., ``NO_OVERLAP``) are created on the instance for each flag definition. """ FLAG_DEFINITIONS: ClassVar = [] domain: ClassVar = '' # Mapping of deprecated flag names to their new names, along with # the deprecation versions used in the warning messages. # _DEPRECATED_SINCE maps each deprecated flag name to the version # in which it was deprecated and must have an entry for every # deprecated name. The deprecated constant names are the uppercase # flag names and are derived from _DEPRECATED_FLAG_NAMES. _DEPRECATED_FLAG_NAMES: ClassVar = {} _DEPRECATED_CONSTANT_NAMES: ClassVar = {} _DEPRECATED_SINCE: ClassVar = {} _DEPRECATED_UNTIL: ClassVar = None def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) missing = set(cls._DEPRECATED_FLAG_NAMES) - set(cls._DEPRECATED_SINCE) if missing: msg = (f'{cls.__name__}._DEPRECATED_SINCE is missing the ' f'deprecated flag names {sorted(missing)}') raise ValueError(msg) cls._DEPRECATED_CONSTANT_NAMES = { old.upper(): new.upper() for old, new in cls._DEPRECATED_FLAG_NAMES.items()} def __init__(self): for flag_def in self.FLAG_DEFINITIONS: # Create uppercase constants (e.g., NO_OVERLAP = 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} def __getattr__(self, name): return deprecated_getattr(self, name, self._DEPRECATED_CONSTANT_NAMES, since=self._DEPRECATED_SINCE.get( name.lower()), until=self._DEPRECATED_UNTIL) @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 : `FlagDefinition` The flag definition. Raises ------ KeyError If the identifier is not found. TypeError If the identifier is not an integer or a string. A `bool` is not accepted. """ if isinstance(identifier, (int, np.integer)) and not isinstance( identifier, bool): identifier = int(identifier) 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): if identifier in self._DEPRECATED_FLAG_NAMES: new_name = self._DEPRECATED_FLAG_NAMES[identifier] since = self._DEPRECATED_SINCE[identifier] warnings.warn( f"The flag name '{identifier}' is deprecated " f"in version {since}. Use " f"'{new_name}' instead. It will be removed in " f'version {self._DEPRECATED_UNTIL}.', PhotutilsDeprecationWarning, 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 def define_flag_docstring(registry, *, indent=0): """ Generate a bullet list of flag names, bit values, and descriptions. Parameters ---------- registry : `FlagRegistry` The flag registry providing the flag definitions. indent : int, optional Number of spaces to indent the bullet list. Returns ------- flag_descriptions : list of str The lines of the bullet list. The first element is an empty string, followed by a "No flags set" bullet for 0 and one bullet per flag giving its bit value, name, and detailed description. """ if not isinstance(registry, FlagRegistry): msg = 'registry must be an instance of FlagRegistry' raise TypeError(msg) indent_str = ' ' * indent # Generate the flag descriptions flag_descriptions = ['', f'{indent_str}* **0** : No flags set.'] for flag_def in registry.FLAG_DEFINITIONS: name = flag_def.name bit_val = flag_def.bit_value desc = flag_def.detailed_description line = f"{indent_str}* **{bit_val}** (``'{name}'``) : {desc}" flag_descriptions.append(line) return flag_descriptions def update_flag_docstring(func, registry, *, indent=0, placeholder=''): """ Update a function docstring with flag documentation. The ``placeholder`` text in the function docstring is replaced with a bullet list of the flag bit values, names, and detailed descriptions generated from the ``registry``. Parameters ---------- func : function The function whose docstring is updated in place. registry : `FlagRegistry` The flag registry providing the flag definitions. indent : int or None, optional Number of spaces to indent the bullet list. If `None`, the placeholder must appear alone on its own line, and the leading indentation of that line is applied to the bullet list. This keeps the list aligned with the surrounding text on all Python versions, including Python 3.13+, which strips the common leading whitespace from docstrings at compile time. placeholder : str, optional The placeholder text to replace in the docstring. Returns ------- func : function The function with an updated docstring. """ if not hasattr(func, '__doc__') or func.__doc__ is None: return func docstring = func.__doc__ if indent is None: pattern = re.compile(rf'^([ \t]*){re.escape(placeholder)}[ \t]*$', re.MULTILINE) def replace(match): flag_descriptions = define_flag_docstring( registry, indent=len(match.group(1))) return '\n'.join(flag_descriptions) func.__doc__ = pattern.sub(replace, docstring) return func if placeholder in docstring: flag_descriptions = define_flag_docstring(registry, indent=indent) # Replace the placeholder with the flag descriptions flag_text = '\n'.join(flag_descriptions) func.__doc__ = docstring.replace(placeholder, flag_text) return func def decode_flags(flags, registry, *, return_bit_values=False): """ Decode bitwise flag values into individual components. Parameters ---------- flags : int or array-like of int Integer flag value(s) to decode. registry : `FlagRegistry` The flag registry providing the flag definitions. return_bit_values : bool, optional If `True`, return the decoded bit flags (integers) instead of the flag names (strings). Returns ------- decoded : list List of active flag names (or bit values) for a scalar input. For an array input, a nested list with the same shape as the input is returned, where each innermost element is the list of active flag names (or bit values) for the corresponding flag. """ flag_definitions = registry.flag_dict def _decode_single_flag(flag_value): """ Decode a single integer flag value. """ if (not isinstance(flag_value, (int, np.integer)) or isinstance(flag_value, bool)): msg = 'Flag value must be an integer' raise TypeError(msg) if flag_value < 0: msg = 'Flag value must be a nonnegative integer' raise ValueError(msg) # A NumPy integer cannot be compared with a bit value that is # out of bounds for its dtype flag_value = int(flag_value) active_flags = [] for bit_value, name in flag_definitions.items(): if flag_value & bit_value: if return_bit_values: active_flags.append(bit_value) else: active_flags.append(name) return active_flags def _decode_array(arr): """ Recursively decode an array, preserving its shape as nested lists. """ if arr.ndim == 1: return [_decode_single_flag(flag) for flag in arr] return [_decode_array(sub) for sub in arr] # 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 0D arrays (scalar arrays) return _decode_single_flag(flags_array.item()) # Handle 1D or higher dimensional arrays, preserving shape return _decode_array(flags_array) astropy-photutils-24be529/photutils/utils/_misc.py000066400000000000000000000036311526452531300224520ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for getting the installed package versions and the current date and time for use in output metadata. """ 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', '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, # so 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-24be529/photutils/utils/_moments.py000066400000000000000000000557141526452531300232120ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating image moments and the centroid, covariance, and shape quantities derived from them. `image_moments` computes the moments of a single 2D image. The ``*_from_moments`` functions take moments arrays with a leading source axis and the layout ``moments[:, i, j]`` equal to the sum of ``y**i * x**j``. The ``(0, 2)`` element is therefore the second moment along ``x`` and the ``(2, 0)`` element is the second moment along ``y``. The covariance functions take ``(N, 2, 2)`` covariance matrices, and `major_axis_angle` takes their ``(N,)`` elements. Array inputs with a leading source axis must have a floating-point dtype. They are not validated or converted. Every function returns a plain `~numpy.ndarray` without units. """ import numpy as np from photutils.utils._wcs_helpers import compute_pixel_to_sky_jacobians # The variance of a uniform distribution across a single pixel. This is # the smallest second moment a resolved source can have given finite # pixel size. PIXEL_VARIANCE = 1.0 / 12.0 # The relative tolerance of the positive semidefinite test of a # covariance matrix. The determinant of a rank-1 matrix is computed with # a rounding error of a few times the machine epsilon relative to its # squared trace. PSD_RTOL = 1000.0 * np.finfo(float).eps 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, optional The ``(x, y)`` center position. The default is ``(0, 0)``, which gives the raw image moments. Passing the centroid gives the central 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 nonnegative' raise ValueError(msg) indices = np.ogrid[tuple(slice(0, i) for i in data.shape)] ypowers = (indices[0] - center[1]) ** np.arange(order + 1) xpowers = np.transpose(indices[1] - center[0]) ** np.arange(order + 1) return np.dot(np.dot(np.transpose(ypowers), data), xpowers) def centroid_from_moments(moments): """ Compute the center-of-mass centroid of each source from its raw moments. Parameters ---------- moments : `~numpy.ndarray` The raw moments with shape ``(N, 2, 2)`` or larger in the trailing axes. Only the elements up to first order are used. Returns ------- centroid : `~numpy.ndarray` The ``(N, 2)`` centroids in ``(x, y)`` order, relative to the origin of the moments. Sources with zero total flux have nonfinite values. """ # Ignore divide-by-zero floating-point errors with np.errstate(divide='ignore', invalid='ignore'): y_centroid = moments[:, 1, 0] / moments[:, 0, 0] x_centroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((x_centroid, y_centroid)) def inertia_tensor_from_moments(moments_central): """ Compute the inertia tensor of each source for rotation around its center of mass. Parameters ---------- moments_central : `~numpy.ndarray` The central moments with shape ``(N, 3, 3)`` or larger in the trailing axes. Only the second-order elements are used. Returns ------- tensor : `~numpy.ndarray` The ``(N, 2, 2)`` inertia tensors, with rows and columns ordered ``(y, x)``. They are not normalized by the total flux. """ mu_02 = moments_central[:, 0, 2] mu_11 = -moments_central[:, 1, 1] mu_20 = moments_central[:, 2, 0] tensor = np.array([mu_02, mu_11, mu_11, mu_20]).swapaxes(0, 1) return tensor.reshape((tensor.shape[0], 2, 2)) def covariance_from_moments(moments_central): """ Compute the raw covariance matrix of the 2D Gaussian function that has the same normalized second-order central moments as each source. Parameters ---------- moments_central : `~numpy.ndarray` The central moments with shape ``(N, 3, 3)`` or larger in the trailing axes. Only the elements up to second order are used. Returns ------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` covariance matrices, before any regularization. Sources with zero total flux have nonfinite elements. """ # Ignore divide-by-zero floating-point errors with np.errstate(divide='ignore', invalid='ignore'): mu_norm = (moments_central / moments_central[:, 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) return covar.reshape((covar.shape[0], 2, 2)) def covariance_determinant(covariance): """ Compute the determinant of each ``(2, 2)`` covariance matrix. The closed form ``a * d - b * c`` is used instead of `numpy.linalg.det`. The LAPACK pivot search does not handle NaN consistently across platforms, so a matrix with a NaN element can give a determinant of zero instead of NaN. The closed form always propagates NaN. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` covariance matrices. Returns ------- determinant : `~numpy.ndarray` The ``(N,)`` determinants. Matrices with NaN elements give NaN. """ # Ignore floating-point errors from nonfinite values in the # covariance and from the overflow of huge variances with np.errstate(over='ignore', invalid='ignore'): return (covariance[:, 0, 0] * covariance[:, 1, 1] - covariance[:, 0, 1] * covariance[:, 1, 0]) def covariance_min_eigval(covariance, *, determinant): """ Compute the smaller eigenvalue of each symmetric ``(2, 2)`` covariance matrix. The smaller eigenvalue has two equivalent closed forms, ``tr/2 - root`` and ``det / (tr/2 + root)``, where ``root`` is half of the difference of the two eigenvalues. For a symmetric matrix ``[[a, b], [b, d]]`` it is ``hypot((a - d) / 2, b)``, which is never negative and does not overflow for large variances. The second form is used for a positive trace. The subtraction in the first form cancels catastrophically when the matrix is highly elongated. The first form is used otherwise, where it has no cancellation and the second form can divide by zero. The accuracy is limited by the accuracy of the input ``determinant``, which has its own cancellation for an elongated matrix with a large off-diagonal element. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` covariance matrices. determinant : `~numpy.ndarray` The ``(N,)`` determinants of ``covariance`` (see `covariance_determinant`). Returns ------- min_eigval : `~numpy.ndarray` The ``(N,)`` smaller eigenvalues (the minor-axis variances). Matrices with NaN elements give NaN. """ # Ignore floating-point errors from nonfinite values in the # covariance and from the unused branch of the np.where call with np.errstate(divide='ignore', over='ignore', invalid='ignore'): var_x = covariance[:, 0, 0] var_y = covariance[:, 1, 1] half_trace = 0.5 * (var_x + var_y) root = np.hypot(0.5 * (var_x - var_y), covariance[:, 0, 1]) return np.where(half_trace > 0, determinant / (half_trace + root), half_trace - root) def covariance_max_eigval(covariance): """ Compute the larger eigenvalue of each symmetric ``(2, 2)`` covariance matrix. The closed form ``tr/2 + root`` is used (see `covariance_min_eigval`). It has no cancellation and does not use the determinant, which can overflow for huge variances. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` covariance matrices. Returns ------- max_eigval : `~numpy.ndarray` The ``(N,)`` larger eigenvalues (the major-axis variances). Matrices with NaN elements give NaN. """ # Ignore floating-point errors from nonfinite values in the # covariance with np.errstate(over='ignore', invalid='ignore'): var_x = covariance[:, 0, 0] var_y = covariance[:, 1, 1] return (0.5 * (var_x + var_y) + np.hypot(0.5 * (var_x - var_y), covariance[:, 0, 1])) def is_invalid_covariance(covariance, *, determinant): """ Return a mask of sources whose raw covariance matrix is not positive semidefinite. A valid covariance is positive semidefinite (determinant and trace both nonnegative). A matrix that is not (e.g., from net-negative flux weighting) does not describe a shape. The determinant of an exactly thin source (a rank-1 matrix) is zero, but its computed value can be slightly negative from rounding. A determinant is therefore treated as negative only if it is below ``-PSD_RTOL`` times the squared trace. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` raw covariance matrices. determinant : `~numpy.ndarray` The ``(N,)`` determinants of ``covariance`` (see `covariance_determinant`). Returns ------- mask : `~numpy.ndarray` The ``(N,)`` boolean mask. Matrices with a NaN diagonal element are never flagged. A matrix with a NaN off-diagonal element is flagged only if its trace is negative. A matrix with an infinite element is flagged only if its trace is negative or if its determinant is negative infinity while its trace is finite. """ # Ignore floating-point errors from nonfinite values in the # covariance and from the overflow of huge variances with np.errstate(over='ignore', invalid='ignore'): covar_trace = covariance[:, 0, 0] + covariance[:, 1, 1] return ((determinant < -PSD_RTOL * covar_trace**2) | (covar_trace < 0)) def is_singular_covariance(covariance, *, determinant): """ Return a mask of sources whose raw covariance matrix is singular or nearly singular. A source is flagged when its minor-axis variance (the smaller eigenvalue of its raw, unregularized covariance matrix) is less than ``PIXEL_VARIANCE``, meaning that at least one axis is unresolved. This covers point-like sources, where both axes are unresolved, and thin sources that are unresolved along only one axis. A test of the determinant against ``PIXEL_VARIANCE**2`` is implied for a symmetric matrix, because a determinant below that value requires an eigenvalue below ``PIXEL_VARIANCE``. A covariance matrix that is not positive semidefinite (see `is_invalid_covariance`) is not flagged. It does not describe a shape, so it is set to NaN instead of being regularized. The flagged sources are therefore exactly the ones that `regularize_covariance` modifies and keeps finite. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` raw covariance matrices. determinant : `~numpy.ndarray` The ``(N,)`` determinants of ``covariance`` (see `covariance_determinant`). Returns ------- mask : `~numpy.ndarray` The ``(N,)`` boolean mask. Sources with a NaN minor-axis variance are never flagged. """ min_eigval = covariance_min_eigval(covariance, determinant=determinant) invalid = is_invalid_covariance(covariance, determinant=determinant) return (min_eigval < PIXEL_VARIANCE) & ~invalid def floor_covariance_eigvals(covariance, *, minimum): """ Raise the eigenvalues of each symmetric ``(2, 2)`` matrix to at least a minimum value while keeping its eigenvectors. The variance along each principal axis is floored independently, so the orientation is unchanged and an axis whose variance already exceeds the minimum is not modified. If both eigenvalues are below the minimum, the result is exactly the minimum times the identity matrix, which has no preferred axis. The result is a continuous function of the input. A floored eigenvalue equals the minimum only to within the rounding error of the matrix elements, which is of order the machine epsilon times the larger eigenvalue. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` symmetric matrices. The array must have a floating-point dtype. This array is not modified. minimum : float or `~numpy.ndarray` The minimum eigenvalue, either a scalar or an ``(N,)`` array with one finite value per matrix. Returns ------- floored : `~numpy.ndarray` A new ``(N, 2, 2)`` array. Matrices whose eigenvalues all reach the minimum are returned unchanged, as are matrices with any nonfinite element. """ floored = covariance.copy() # Eigenvalues of each symmetric 2x2 matrix in closed form, which # avoids the per-matrix overhead of a general solver. The smaller # eigenvalue comes from `covariance_min_eigval`, which does not # lose precision for a highly elongated matrix. Its determinant can # overflow for huge variances. That gives a smaller eigenvalue that # is infinite or NaN, which compares false below, so the matrix is # returned unchanged. A matrix with a nonfinite element has a # nonfinite eigenvalue and is also returned unchanged. determinant = covariance_determinant(covariance) eig_min = covariance_min_eigval(covariance, determinant=determinant) eig_max = covariance_max_eigval(covariance) minimum = np.broadcast_to(minimum, eig_min.shape) # A matrix with both eigenvalues below the minimum becomes exactly # isotropic. idx = np.flatnonzero(eig_max < minimum) floored[idx] = minimum[idx, np.newaxis, np.newaxis] * np.eye(2) # Otherwise raise only the smaller eigenvalue. The projector onto the # minor axis is (eig_max * I - C) / (eig_max - eig_min), so adding # (minimum - eig_min) times the projector changes that eigenvalue # and keeps both eigenvectors. The three unique elements are updated # directly, so the result is exactly symmetric. An infinite # off-diagonal element with a nonpositive trace gives eigenvalues # of negative and positive infinity, which pass the comparisons, so # both eigenvalues must also be finite. idx = np.flatnonzero((eig_min < minimum) & (eig_max >= minimum) & np.isfinite(eig_min) & np.isfinite(eig_max)) eig_max = eig_max[idx] # Ignore floating-point errors from the overflow of a huge matrix # that is not positive semidefinite, whose eigenvalue difference can # exceed the largest finite value with np.errstate(over='ignore', invalid='ignore'): factor = (minimum[idx] - eig_min[idx]) / (eig_max - eig_min[idx]) covar_xy = factor * covariance[idx, 0, 1] floored[idx, 0, 0] += factor * (eig_max - covariance[idx, 0, 0]) floored[idx, 1, 1] += factor * (eig_max - covariance[idx, 1, 1]) floored[idx, 0, 1] -= covar_xy floored[idx, 1, 0] -= covar_xy return floored def regularize_covariance(covariance, *, determinant): """ Regularize the raw covariance matrices of undefined and unresolved sources. A matrix that is not positive semidefinite (see `is_invalid_covariance`) has an undefined shape and is set to NaN. The remaining finite matrices have each eigenvalue (the variance along a principal axis) raised to at least ``PIXEL_VARIANCE``, the smallest second moment a source can have given finite pixel size. The eigenvectors are kept, so the orientation is unchanged and a resolved axis is not modified. Only the sources selected by `is_singular_covariance` have an eigenvalue below that value, so every other matrix is returned unchanged. The regularized matrix is a continuous function of the raw matrix. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` raw covariance matrices. This array is not modified. It must have a floating-point dtype because invalid matrices are set to NaN. determinant : `~numpy.ndarray` The ``(N,)`` determinants of ``covariance`` (see `covariance_determinant`). Returns ------- regularized : `~numpy.ndarray` A new ``(N, 2, 2)`` array of regularized covariance matrices. """ # Matrices with a nonfinite element are not changed by the floor covar = floor_covariance_eigvals(covariance, minimum=PIXEL_VARIANCE) invalid = is_invalid_covariance(covariance, determinant=determinant) covar[invalid] = np.nan return covar def eigvals_from_covariance(covariance): """ Compute the two eigenvalues of each covariance matrix in decreasing order. The input is expected to be regularized (see `regularize_covariance`). The smaller eigenvalue of a raw matrix that is exactly singular can be slightly negative from rounding, which gives NaN eigenvalues. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` covariance matrices. Returns ------- eigvals : `~numpy.ndarray` The ``(N, 2)`` eigenvalues, largest first. Both eigenvalues are NaN for a matrix with any nonfinite element or with a negative eigenvalue (a matrix that is not positive semidefinite). """ # Eigenvalues of each symmetric 2x2 matrix in closed form, which # avoids the per-matrix overhead of a general solver. The # determinant form of the smaller eigenvalue is accurate for a # highly elongated matrix, but the determinant overflows for huge # variances. In that case the determinant is divided by the larger # eigenvalue one factor at a time, which cannot overflow. determinant = covariance_determinant(covariance) eig_min = covariance_min_eigval(covariance, determinant=determinant) eig_max = covariance_max_eigval(covariance) # Ignore floating-point errors from nonfinite values in the # covariance and from a zero larger eigenvalue with np.errstate(divide='ignore', over='ignore', invalid='ignore'): covar_xy = covariance[:, 0, 1] scaled = (covariance[:, 0, 0] * (covariance[:, 1, 1] / eig_max) - covar_xy * (covar_xy / eig_max)) eig_min = np.where(np.isfinite(determinant), eig_min, scaled) eigvals = np.transpose((eig_max, eig_min)) # A nonfinite element gives a NaN or infinite eigenvalue, and a # negative variance means that the matrix is not positive # semidefinite good = np.isfinite(eigvals).all(axis=1) & (eig_min >= 0) eigvals[~good] = np.nan return eigvals def major_axis_angle(var_a, var_b, covar_ab): """ Compute the angle from the ``a`` axis toward the ``b`` axis of the major axis of a 2D Gaussian function. Parameters ---------- var_a, var_b : `~numpy.ndarray` The ``(N,)`` variances along the two axes. covar_ab : `~numpy.ndarray` The ``(N,)`` covariances of the two axes. Returns ------- angle : `~numpy.ndarray` The ``(N,)`` angles in degrees, in the range (-90, 90]. The angle is zero for an isotropic matrix and NaN where the difference of the variances is undefined. """ # Ignore floating-point errors from nonfinite values (e.g., the # difference of two infinite variances). Adding zero turns a # negative-zero covariance into positive zero, which keeps the angle # at 90 instead of -90 degrees when var_a < var_b. with np.errstate(over='ignore', invalid='ignore'): angle = 0.5 * np.arctan2(2.0 * covar_ab + 0.0, var_a - var_b) return np.rad2deg(angle) def orientation_from_covariance(covariance): """ Compute the angle between the x-axis and the major axis of the 2D Gaussian function described by each covariance matrix. Parameters ---------- covariance : `~numpy.ndarray` The ``(N, 2, 2)`` pixel covariance matrices. Returns ------- orientation : `~numpy.ndarray` The ``(N,)`` orientation angles in degrees. The angle increases in the counterclockwise direction and is in the range (-90, 90]. """ return major_axis_angle(covariance[:, 0, 0], covariance[:, 1, 1], covariance[:, 0, 1]) def pixel_to_sky_covariance(wcs, pixel_covariance, xycen): """ Transport pixel covariance matrices to the local tangent plane. The pixel covariance of each source is mapped to the local tangent plane with the forward WCS Jacobian ``F`` evaluated at the source position (``sky_covariance = F pixel_covariance F^T``). Parameters ---------- wcs : WCS object A WCS object that implements the `astropy shared interface for WCS `_. pixel_covariance : `~numpy.ndarray` The ``(N, 2, 2)`` pixel covariance matrices. xycen : `~numpy.ndarray` The ``(N, 2)`` pixel ``(x, y)`` centroid positions at which to evaluate the WCS Jacobians. Returns ------- sky_covariance : `~numpy.ndarray` The ``(N, 2, 2)`` tangent-plane covariance matrices in arcsec**2, with rows and columns ordered ``(East, North)``. Matrices are NaN where the position or covariance is not finite. """ sky_covariance = np.full(pixel_covariance.shape, np.nan) good = (np.all(np.isfinite(xycen), axis=1) & np.all(np.isfinite(pixel_covariance), axis=(1, 2))) if np.any(good): jac = compute_pixel_to_sky_jacobians(wcs, xycen[good, 0], xycen[good, 1]) # The Einstein summation computes the matrix product of the # Jacobian, the pixel covariance, and the Jacobian transpose # for each source. The result is the sky covariance matrix in # the local tangent plane. sky_covariance[good] = np.einsum('nij,njk,nlk->nil', jac, pixel_covariance[good], jac) return sky_covariance def sky_orientation_from_covariance(sky_covariance): """ Compute the sky position angle of the major axis from tangent-plane covariance matrices. Parameters ---------- sky_covariance : `~numpy.ndarray` The ``(N, 2, 2)`` tangent-plane covariance matrices, with rows and columns ordered ``(East, North)``. Returns ------- sky_orientation : `~numpy.ndarray` The ``(N,)`` position angles in degrees, measured from North toward East and in the range (-90, 90]. """ # The tangent-plane axes are ordered (East, North). Measuring the # angle from North toward East makes North play the role of the # first axis and East the role of the second axis. return major_axis_angle(sky_covariance[:, 1, 1], sky_covariance[:, 0, 0], sky_covariance[:, 0, 1]) astropy-photutils-24be529/photutils/utils/_optional_deps.py000066400000000000000000000064601526452531300243620ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for optional dependencies. Attributes ``HAS_`` (e.g., ``HAS_MATPLOTLIB``, ``HAS_REGIONS``) 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 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 = { 'asdf-astropy': 'asdf_astropy', } 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, ``'asdf-astropy'`` -> ``'ASDF_ASTROPY'`` and ``'matplotlib'`` -> ``'MATPLOTLIB'``. """ import_name = _DIST_TO_IMPORT.get(dist_name, dist_name) return import_name.upper().replace('-', '_').replace('.', '_') # The distribution name of this package is the same as its top-level # import name. Looking it up with # ``importlib.metadata.packages_distributions`` would read the metadata # of every installed distribution on import. _pkg_dist_name = __name__.split('.', maxsplit=1)[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-24be529/photutils/utils/_overlap.py000066400000000000000000000036711526452531300231730ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for computing the overlap of a small array with a large array. """ from astropy.nddata import overlap_slices as _astropy_overlap_slices def overlap_slices(large_array_shape, small_array_shape, position, *, mode='partial'): """ Get slices for the overlapping part of a small and a large array. This is a thin wrapper around `astropy.nddata.overlap_slices` that converts ``small_array_shape`` to a tuple. The astropy function compares ``small_array_shape`` to a tuple, which raises a `ValueError` for a `~numpy.ndarray` shape (e.g., as returned by `~photutils.utils._parameters.as_pair`) when the small array ends exactly at the lower edge of the large array. With a tuple, the expected `~astropy.nddata.NoOverlapError` is raised instead. Parameters ---------- large_array_shape : tuple of int The shape of the large array. small_array_shape : array_like of int The shape of the small array. position : tuple of float The position of the small array's center with respect to the large array, in the same axis order as the shapes. mode : {'partial', 'trim', 'strict'}, optional The overlap mode. See `astropy.nddata.overlap_slices`. Returns ------- slices_large : tuple of slice 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. slices_small : tuple of slice A tuple of slice objects for each axis of the small array, such that ``small_array[slices_small]`` extracts the region that is inside the large array. """ return _astropy_overlap_slices(large_array_shape, tuple(small_array_shape), position, mode=mode) astropy-photutils-24be529/photutils/utils/_parameters.py000066400000000000000000000150171526452531300236630ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for parameter validation. """ import warnings import numpy as np from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning 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 the 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. If ``check_odd`` is `True`, a warning is issued when the value is reset. If the bound is even, the value is also reduced to the next lower odd value. 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 not in 'iu': 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. # The upper_bound is typically set to an image shape is_clamped = np.any(value > np.asarray(upper_bound)) value = np.array((min(value[0], upper_bound[0]), min(value[1], upper_bound[1]))) # Clamping to an even bound can produce an even value. Reduce # it to the next lower odd value to preserve the odd-values # requirement. if check_odd and is_clamped: even = (value % 2 == 0) & (value > 0) value = value - even.astype(int) new_value = tuple(int(val) for val in value) if np.any(even): msg = (f'{name!r} was clamped to the upper bound and ' f'reduced to {new_value} to keep odd values ' 'for both axes') else: msg = f'{name!r} was clamped to the upper bound {new_value}' warnings.warn(msg, AstropyUserWarning) return value def validate_table_columns(columns, allowed_columns, *, deprecated_names=None): """ Validate requested ``to_table()`` column names. This is a helper function for ``to_table`` methods that accept a ``columns`` keyword. It normalizes ``columns`` to a list and raises a `ValueError` if any requested name is not in ``allowed_columns`` or ``deprecated_names``. Parameters ---------- columns : str or list of str The requested column name(s). allowed_columns : iterable of str The allowed column names. deprecated_names : iterable of str, optional Deprecated column names (e.g., the keys of a class's ``_DEPRECATED_ATTRIBUTES`` mapping) that should still be accepted in addition to ``allowed_columns``. Returns ------- table_columns : list of str ``columns`` normalized to a list of strings. Raises ------ ValueError If any name in ``columns`` is not an allowed or deprecated column name. """ table_columns = [columns] if isinstance(columns, str) else list(columns) allowed = set(allowed_columns) if deprecated_names is not None: allowed |= set(deprecated_names) invalid = [col for col in table_columns if col not in allowed] if invalid: msg = (f'Invalid column name(s): {invalid!r}. The allowed ' f'column names are: {sorted(allowed)}.') raise ValueError(msg) return table_columns astropy-photutils-24be529/photutils/utils/_progress_bars.py000066400000000000000000000050201526452531300243640ustar00rootroot00000000000000# 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-24be529/photutils/utils/_quantity_helpers.py000066400000000000000000000072461526452531300251250ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Helper functions for working with Quantity objects. """ 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`, `~astropy.units.Quantity`, \ or `None` A list of values. `None` values are ignored. names : list of str A list of names corresponding to the input ``values``. Returns ------- units : set The set of distinct units of the non-`None` values. A value without a unit contributes `None`. The set is empty if all values are `None`. 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 all of the non-`None` values must have the same units. `None` values are passed through unchanged. The input values are returned with their units removed, along with the common unit. Parameters ---------- values : list of scalar, `~numpy.ndarray`, `~astropy.units.Quantity`, \ or `None` 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.units.UnitBase` or `None` The common unit for the input values. `None` is returned if none of the input values have units (including when all values are `None`). 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. """ units = check_units(values, names) # When all values are None, the units set is empty, so 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-24be529/photutils/utils/_repr.py000066400000000000000000000052661526452531300224750ustar00rootroot00000000000000# 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. Ignored if ``long`` is `True`. 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 multiline "long" format typically used by __str__. It has the fully qualified class name in angle brackets followed by one ``name: value`` line per parameter. 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) 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-24be529/photutils/utils/_round.py000066400000000000000000000023221526452531300226420ustar00rootroot00000000000000# 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 `~numpy.ndarray` The rounded values. Finite inputs are returned as integers. Nonfinite inputs (NaN or infinity) are returned as floats, preserving the NaN or infinity value. Scalar and 0-d array inputs return a scalar. Notes ----- NaN and infinity values are preserved in the output. Arrays containing any nonfinite 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.ndim(a) == 0: val = rounded[0] if not np.isfinite(val): return val return int(val) if np.isfinite(data).all(): return rounded.astype(int) return rounded astropy-photutils-24be529/photutils/utils/_stats.py000066400000000000000000000110421526452531300226500ustar00rootroot00000000000000# 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 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` or `~astropy.units.Quantity` 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`, `~astropy.units.Quantity`, 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 work around 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-24be529/photutils/utils/_wcs_helpers.py000066400000000000000000001120671526452531300240410ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Private helpers for local WCS Jacobians, isotropic and principal-axis pixel scales, and ellipse conversions between the pixel and sky frames. """ import sys import astropy.units as u import numpy as np from astropy.coordinates import (Angle, BaseCoordinateFrame, frame_transform_graph) from astropy.wcs.wcsapi import high_level_objects_to_values def _low_level_wcs(wcs): """ Return the low-level WCS behind a high-level WCS object. Objects that implement the astropy high-level WCS interface expose their low-level counterpart as ``low_level_wcs`` (for `astropy.wcs.WCS` and `gwcs.wcs.WCS` that is the object itself). An object without the attribute is used as is. """ return getattr(wcs, 'low_level_wcs', wcs) def _is_gwcs(wcs): """ Return True if the low-level WCS is a `gwcs.wcs.WCS` object. Only a gwcs object has a bounding box to bypass. A gwcs instance can exist only if its defining module has been imported, so a missing ``sys.modules`` entry means there is no gwcs object to detect. The check never imports gwcs itself. """ # The HAS_GWCS optional dependency flag is deliberately not used. # Resolving it imports gwcs whenever it is installed, which costs # tens of milliseconds on the first call even for a workflow that # only ever uses astropy FITS WCS objects. The function-level # imports also cost about 1 us per call. Looking up sys.modules # costs about 100 ns and imports nothing. gwcs_wcs = sys.modules.get('gwcs.wcs') gwcs_cls = getattr(gwcs_wcs, 'WCS', None) return gwcs_cls is not None and isinstance(wcs, gwcs_cls) def _to_radians(values, unit): """ Convert world coordinate values from the given unit to radians. """ if unit == 'deg': return np.deg2rad(values) return np.asarray(values, dtype=float) * u.Unit(unit).to(u.rad) def _frame_rotation(wcs, skycoord): """ Return the rotation from the frame of a sky coordinate to the celestial frame of a WCS. The rotation is the position angle, measured in the WCS frame at ``skycoord``, of the North direction of the ``skycoord`` frame. Adding it to a position angle measured in the ``skycoord`` frame gives the position angle in the WCS frame, which is the frame of the local Jacobian. It is zero when the two frames are equivalent or when the WCS does not define a celestial frame. 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`). skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate. Returns ------- rotation : float The rotation angle in radians. """ low_level_wcs = _low_level_wcs(wcs) key = next(comp[0] for comp in low_level_wcs.world_axis_object_components if comp[1] == 0) frame = low_level_wcs.world_axis_object_classes[key][2].get('frame') # A gwcs celestial frame is often given by name (e.g., 'icrs'). # Resolve a name or a class to a frame instance with default # attributes so that it can be compared with the skycoord frame. if isinstance(frame, str): frame = frame_transform_graph.lookup_name(frame) or frame if isinstance(frame, type): frame = frame() if frame is None or (isinstance(frame, BaseCoordinateFrame) and skycoord.is_equivalent_frame(frame)): return 0.0 center = skycoord.transform_to(frame) north = skycoord.directional_offset_by(0.0, 1 * u.arcsec) return center.position_angle(north.transform_to(frame)).to_value(u.rad) def _pixel_to_world_radians(wcs, x, y): """ Convert pixel coordinates to world longitude and latitude in radians, ignoring any gwcs bounding box. The finite differences in `compute_pixel_to_sky_jacobians` step half a pixel beyond the source position. For a source in the outermost pixel of the array those steps fall outside a gwcs bounding box, where gwcs returns NaN. The forward transform is well defined there, so it is evaluated with the bounding box disabled. The bypass applies only to a gwcs object passed directly or through a high-level wrapper. A gwcs behind an astropy `~astropy.wcs.wcsapi.SlicedLowLevelWCS` is evaluated through the slice, which keeps the bounding box, so its edge pixels can give NaN. The low-level WCS interface is used because it returns plain arrays. The high-level interface builds a `~astropy.coordinates.SkyCoord`, which costs far more than the transform itself for a handful of positions. The longitude and latitude outputs are identified by their component index in ``world_axis_object_components``, so a WCS whose latitude axis comes first (e.g., ``CTYPE1 = 'DEC--TAN'``) is handled correctly. 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`). x, y : `~numpy.ndarray` The pixel coordinates. Returns ------- lon, lat : `~numpy.ndarray` The world longitude and latitude in radians. """ low_level_wcs = _low_level_wcs(wcs) # The second element of each world_axis_object_components entry # is the index of the axis within the sky coordinate, 0 for the # longitude and 1 for the latitude, whatever the axis order of the # WCS. The check comes before the transform so that an invalid WCS # is rejected without evaluating it. component_indices = [comp[1] for comp in low_level_wcs.world_axis_object_components] if component_indices.count(0) != 1 or component_indices.count(1) != 1: msg = ('wcs must have exactly one celestial longitude axis and ' 'one celestial latitude axis') raise ValueError(msg) lon_idx = component_indices.index(0) lat_idx = component_indices.index(1) units = low_level_wcs.world_axis_units if _is_gwcs(low_level_wcs): world = low_level_wcs(x, y, with_bounding_box=False) else: world = low_level_wcs.pixel_to_world_values(x, y) return (_to_radians(world[lon_idx], units[lon_idx]), _to_radians(world[lat_idx], units[lat_idx])) def _world_to_pixel(wcs, skycoord): """ Convert a sky coordinate to pixel coordinates, ignoring any gwcs bounding box. See `_pixel_to_world_radians` for why the bounding box is bypassed. The forward transform is well defined outside the box, so a source just beyond the array edge inverts to a finite pixel position instead of NaN. 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`). skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate(s). Returns ------- x, y : float or `~numpy.ndarray` The pixel coordinates. """ low_level_wcs = _low_level_wcs(wcs) if _is_gwcs(low_level_wcs): values = high_level_objects_to_values(skycoord, low_level_wcs=low_level_wcs) return low_level_wcs.invert(*values, with_bounding_box=False) return wcs.world_to_pixel(skycoord) def _check_jacobian_defined(jacobian, pixcoord): """ Raise a ValueError if a Jacobian is not finite. A nonfinite Jacobian means the WCS is undefined at the position, for example outside the valid region of an all-sky projection. The per-region helpers raise here so that the caller gets a clear message instead of a NaN scale or a failed decomposition later. Parameters ---------- jacobian : `~numpy.ndarray` The 2x2 Jacobian to check. pixcoord : tuple of float The ``(x, y)`` pixel position, used in the error message. """ if not np.all(np.isfinite(jacobian)): x, y = pixcoord msg = (f'The WCS is undefined at pixel position ({x}, {y}), ' 'e.g., outside the valid region of the projection') raise ValueError(msg) def _sky_to_pixel_jacobian(wcs, skycoord, *, pixcoord=None): """ Compute the pixel center and the local Jacobian for a sky-to-pixel conversion. The pixel position of ``skycoord`` is found by inverting the WCS unless it is given. The forward Jacobian ``F = d(sky_arcsec)/ d(pixel)`` is then evaluated there with `compute_pixel_to_sky_jacobians`, by central differences half a pixel either side of the position, and inverted to give ``J = F^{-1} = d(pixel)/d(sky_arcsec)``. 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`). skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. pixcoord : tuple of float, optional The ``(x, y)`` pixel position of ``skycoord``, if already known. When given, the WCS is not inverted to find it and ``skycoord`` is used only to name the position in an error message (it may be `None`). Returns ------- center : tuple of float The ``(x, y)`` pixel center position. jacobian : 2x2 `~numpy.ndarray` The Jacobian matrix ``d(pixel)/d(sky_arcsec)``. """ if pixcoord is None: x0, y0 = _world_to_pixel(wcs, skycoord) else: x0, y0 = pixcoord if not (np.isfinite(x0) and np.isfinite(y0)): # Callers often invert the WCS themselves and pass the position # along with the sky coordinate, so name the sky coordinate # whenever it is available. if skycoord is None: msg = f'The pixel position ({x0}, {y0}) is not finite' else: coord = ' '.join(np.atleast_1d(skycoord.to_string())) msg = (f'The sky coordinate ({coord} {skycoord.frame.name}) ' 'has no pixel position under the WCS, e.g., it lies ' 'outside the valid region of the projection') raise ValueError(msg) center = (float(x0), float(y0)) forward = compute_pixel_to_sky_jacobians(wcs, x0, y0)[0] _check_jacobian_defined(forward, center) # The explicit 2x2 inverse is much faster than np.linalg.inv on a # single small matrix. det = forward[0, 0] * forward[1, 1] - forward[0, 1] * forward[1, 0] if det == 0: msg = f'The WCS Jacobian is singular at pixel position {center}' raise ValueError(msg) inverse = np.array([[forward[1, 1], -forward[0, 1]], [-forward[1, 0], forward[0, 0]]]) / det return center, inverse def _svd_ellipse_from_composite(m_comp, *, sky_angle=False, input_circular=False): """ Extract ellipse widths, heights, and angles from composite matrices using SVD. Given 2x2 matrices ``m_comp`` whose columns represent the mapped semiaxis vectors of ellipses, perform SVD and return the full widths, heights, and rotation angles, preserving the width/height assignment of the input ellipses. Parameters ---------- m_comp : `~numpy.ndarray` The composite matrices, with shape ``(..., 2, 2)``, whose SVD gives the output ellipse axes. The first column is the mapped width semiaxis and the second the mapped height semiaxis. sky_angle : bool, optional If True, the composite matrix columns are tangent-plane (``xi`` = East, ``eta`` = North) vectors, so the returned angle is a sky position angle (PA) measured from North toward East. If False (the default), the columns are pixel (``x``, ``y``) vectors and the returned angle is measured counterclockwise from the positive x-axis. input_circular : bool or array_like of bool, optional Whether each input ellipse is known to be circular (width == height), in which case the SVD's principal axis is meaningless and the rotation angle is taken directly from the mapped width semiaxis. Broadcast against the leading shape of ``m_comp``. Default is False. Returns ------- out_width : `~numpy.ndarray` The full widths of the output ellipses, with shape ``(...)``. out_height : `~numpy.ndarray` The full heights of the output ellipses, with shape ``(...)``. angle : `~astropy.coordinates.Angle` The rotation angles of the width axes, wrapped to [0, 360) degrees, with shape ``(...)``. """ 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 semiaxis. width_col = m_comp[..., :, 0] dot_major = np.abs(np.einsum('...i,...i->...', u_mat[..., :, 0], width_col)) dot_minor = np.abs(np.einsum('...i,...i->...', u_mat[..., :, 1], width_col)) # A strict comparison keeps a zero-length width column, whose dot # products are both zero, from being taken as the major axis. major_is_width = dot_major > dot_minor out_width = 2 * np.where(major_is_width, s_vals[..., 0], s_vals[..., 1]) out_height = 2 * np.where(major_is_width, s_vals[..., 1], s_vals[..., 0]) angle_col = np.where(major_is_width[..., np.newaxis], u_mat[..., :, 0], u_mat[..., :, 1]) # Fix SVD sign ambiguity. Ensure the angle direction aligns with the # mapped width semiaxis. flip = np.einsum('...i,...i->...', angle_col, width_col) < 0 angle_col = np.where(flip[..., np.newaxis], -angle_col, angle_col) # When the input ellipse is circular (width == height), the SVD's # principal direction has no physical meaning. Any rotation of a # circle yields an identical shape, so the SVD picks an arbitrary # principal axis derived from the Jacobian. To preserve the input # rotation angle, fall back to the mapped width semiaxis direction # (which carries the input theta through the Jacobian). width_norm = np.linalg.norm(width_col, axis=-1) use_width = (np.broadcast_to(input_circular, width_norm.shape) & (width_norm > 0)) safe_norm = np.where(width_norm > 0, width_norm, 1.0) angle_col = np.where(use_width[..., np.newaxis], width_col / safe_norm[..., np.newaxis], angle_col) if sky_angle: # Sky position angle (PA) measured from North (eta/Dec) toward # East. The composite-matrix columns are tangent-plane vectors # with components (xi=East, eta=North), so the PA is simply # arctan2(xi, eta). The local Jacobian (or its inverse) used to # build the composite matrix already encodes the WCS parity, so # no additional parity correction is needed here. angle_rad = np.arctan2(angle_col[..., 0], angle_col[..., 1]) else: # Pixel angle: measured from +x toward +y angle_rad = np.arctan2(angle_col[..., 1], angle_col[..., 0]) angle = Angle(np.rad2deg(angle_rad) * u.deg).wrap_at(360 * u.deg) return out_width, out_height, angle def _geometric_mean_singular_values(matrices): """ Return the geometric mean of the two singular values of 2x2 matrices. The product of the singular values is the absolute determinant, so the geometric mean is its square root and needs no singular value decomposition. A circle scaled by it has the same area as the ellipse that the matrix maps the unit circle to, and the geometric means of a matrix and its inverse multiply to exactly one. Parameters ---------- matrices : `~numpy.ndarray` An array of shape ``(..., 2, 2)``. Returns ------- result : `~numpy.ndarray` The geometric mean singular value of each matrix, with shape ``(...)``. """ # The explicit 2x2 determinant is much faster than np.linalg.det on # a stack of small matrices and propagates NaN without raising a # warning. det = (matrices[..., 0, 0] * matrices[..., 1, 1] - matrices[..., 0, 1] * matrices[..., 1, 0]) return np.sqrt(np.abs(det)) def compute_pixel_to_sky_jacobians(wcs, x, y): """ Compute local forward WCS Jacobians for an array of pixel positions using central finite differences. Each 2x2 Jacobian ``F`` maps pixel-coordinate offsets to tangent-plane offsets in arcsec:: [d_xi, d_eta]^T ~ F @ [dx, dy]^T where ``xi`` is the offset along East (the Right Ascension direction, as a great-circle angle) and ``eta`` is the offset along North (the Declination direction). Each derivative is the central difference between the sky positions half a pixel either side of the center, so it is unbiased where the distortion has curvature. The sky positions are handled as unit vectors and the differences are projected onto the local East and North directions, so the formula is well-defined at the celestial poles and across the longitude wraparound (RA = 0 / 360). The differences use the chord between the offset unit vectors rather than the arc, which lowers each element by a relative ``h**2 / 24`` for a pixel that subtends an angle ``h`` in radians. That is 1e-7 at 0.1 deg per pixel and 1e-5 at 1 deg per pixel. The truncation error of the half-pixel stencil is of the same order and depends on the projection (``h**2 / 12`` for a TAN projection at the tangent point). All five positions per pixel are evaluated in a single low-level WCS call, which returns plain arrays instead of a `~astropy.coordinates.SkyCoord`. 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`). x, y : float or array_like The pixel coordinates. Arrays are flattened, so the Jacobians are returned in the flattened order. Returns ------- jacobians : `~numpy.ndarray` The (N, 2, 2) array of forward Jacobians in arcsec / pixel, with rows ``(xi, eta)`` and columns ``(x, y)``. """ x = np.asarray(x, dtype=float).ravel() y = np.asarray(y, dtype=float).ravel() if x.size != y.size: msg = 'x and y must have the same size' raise ValueError(msg) n = x.size # Evaluate the pixel centers and the four edge points half a pixel # either side of each center in a single WCS call. The blocks are # ordered center, -x, +x, -y, +y. xx = np.concatenate((x, x - 0.5, x + 0.5, x, x)) yy = np.concatenate((y, y, y, y - 0.5, y + 0.5)) lon, lat = _pixel_to_world_radians(wcs, xx, yy) # Unit vectors pointing at the four edge positions. The center block # is needed only through its longitude and latitude below. cos_lat = np.cos(lat) edge_lon = lon[n:] edge_cos_lat = cos_lat[n:] xyz = np.stack((edge_cos_lat * np.cos(edge_lon), edge_cos_lat * np.sin(edge_lon), np.sin(lat[n:])), axis=-1).reshape(4, n, 3) # Local East (+longitude) and North (+latitude) unit vectors at the # centers. The closed forms stay defined at the poles, where they # follow the nominal longitude of the center. lon0 = lon[:n] lat0 = lat[:n] sin_lon0 = np.sin(lon0) cos_lon0 = np.cos(lon0) sin_lat0 = np.sin(lat0) east = np.stack((-sin_lon0, cos_lon0, np.zeros(n)), axis=-1) north = np.stack((-sin_lat0 * cos_lon0, -sin_lat0 * sin_lon0, cos_lat[:n]), axis=-1) # The central difference of the edge unit vectors, projected onto # East and North, gives the tangent-plane (xi, eta) displacement per # pixel in radians. This formulation has no longitude subtraction # and no division by cos(lat), so it is wrap-safe and pole-safe. # The edge blocks are ordered -x, +x, -y, +y. arcsec_per_rad = 3600.0 * np.degrees(1) jacobians = np.empty((n, 2, 2)) for col, (lo, hi) in enumerate(((0, 1), (2, 3))): step = xyz[hi] - xyz[lo] jacobians[:, 0, col] = np.einsum('ij,ij->i', step, east) jacobians[:, 1, col] = np.einsum('ij,ij->i', step, north) return jacobians * arcsec_per_rad def sky_to_pixel_mean_scale(wcs, skycoord, *, pixcoord=None): """ Compute the pixel center and isotropic (mean) scale factor for a sky-to-pixel conversion. 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 geometric mean of the two singular values of the local Jacobian ``J = d(pixel)/d(sky_arcsec)``, which are the maximum and minimum stretch factors of the mapping. The geometric mean is the square root of the absolute determinant, so a circle scaled by it has the same area as the ellipse that the mapping produces, and a conversion to pixels followed by the conversion back to the sky returns the original radius to within rounding. For a WCS without distortion or skew and with equal pixel scales in x and y, the two singular values are equal at the tangent point and the scale is exact. For distorted WCS, nonsquare pixels, or positions far from the tangent point, the two singular values differ and the geometric mean is the area-preserving compromise. 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`). skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. pixcoord : tuple of float, optional The ``(x, y)`` pixel position of ``skycoord``, if already known. When given, the WCS is not inverted to find it and ``skycoord`` is used only to name the position in an error message (it may be `None`). Returns ------- center : tuple of float The ``(x, y)`` pixel center position. mean_scale : float The mean scale factor (pixels per arcsec). """ center, jacobian = _sky_to_pixel_jacobian(wcs, skycoord, pixcoord=pixcoord) return center, float(_geometric_mean_singular_values(jacobian)) def pixel_to_sky_mean_scale(wcs, pixcoord): """ Compute the isotropic (mean) scale factor for a pixel-to-sky conversion. This is the inverse of `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 scale factor is the geometric mean of the two singular values of the local forward Jacobian ``F = d(sky_arcsec)/d(pixel)``, which are the maximum and minimum angular extents per pixel. It is the exact inverse of the sky-to-pixel scale at the same position. 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`). pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. Returns ------- mean_scale : float The mean scale factor (arcsec per pixel). """ jacobian = compute_pixel_to_sky_jacobians(wcs, pixcoord[0], pixcoord[1])[0] _check_jacobian_defined(jacobian, pixcoord) return float(_geometric_mean_singular_values(jacobian)) def pixel_shape_to_sky_svd(wcs, pixcoord, width, height, pixel_angle_rad): """ Convert a pixel ellipse to a sky ellipse using SVD. This builds the composite matrix ``M_sky = F @ M_pix`` where ``M_pix`` encodes the pixel ellipse semiaxes and rotation, and ``F = d(sky_arcsec)/d(pixel)`` is the local forward Jacobian. The SVD of ``M_sky`` gives the exact sky ellipse semiaxes 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 ---------- 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`). pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the ellipse center. width : float or array_like The full width of the pixel ellipse (before rotation) in pixels. Arrays convert several concentric ellipses with the same center and rotation, such as the two shapes of an annulus, in a single WCS evaluation. height : float or array_like The full height of the pixel ellipse (before rotation) in pixels. Broadcast against ``width``. 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 ------- sky_width : float or `~numpy.ndarray` The full width of the sky ellipse in arcsec, with the broadcast shape of ``width`` and ``height``. sky_height : float or `~numpy.ndarray` The full height of the sky ellipse in arcsec, with the broadcast shape of ``width`` and ``height``. 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, with the broadcast shape of ``width`` and ``height``. """ forward = compute_pixel_to_sky_jacobians(wcs, pixcoord[0], pixcoord[1])[0] _check_jacobian_defined(forward, pixcoord) # Build M_pix: columns are pixel semiaxis vectors, one matrix per # broadcast width and height. width = np.asarray(width, dtype=float) height = np.asarray(height, dtype=float) shape = np.broadcast_shapes(width.shape, height.shape) cos_a = np.cos(pixel_angle_rad) sin_a = np.sin(pixel_angle_rad) half_w = np.broadcast_to(0.5 * width, shape) half_h = np.broadcast_to(0.5 * height, shape) m_pix = np.empty((*shape, 2, 2)) m_pix[..., 0, 0] = half_w * cos_a m_pix[..., 0, 1] = -half_h * sin_a m_pix[..., 1, 0] = half_w * sin_a m_pix[..., 1, 1] = half_h * cos_a # M_sky = F @ M_pix: columns are sky semiaxis vectors. m_sky = forward @ m_pix sky_width, sky_height, sky_angle = _svd_ellipse_from_composite( m_sky, sky_angle=True, input_circular=np.isclose(width, height)) if not shape: return float(sky_width), float(sky_height), sky_angle return sky_width, sky_height, sky_angle def sky_shape_to_pixel_svd(wcs, skycoord, width_arcsec, height_arcsec, sky_angle_rad, *, pixcoord=None): """ 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 semiaxes and rotation, and ``J`` is the local Jacobian. The SVD of ``M_pix`` gives the exact pixel ellipse semiaxes 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 ---------- 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`). skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the ellipse center. width_arcsec : float or array_like The full width of the sky ellipse in arcsec. Arrays convert several concentric ellipses with the same center and rotation, such as the two shapes of an annulus, in a single WCS evaluation. height_arcsec : float or array_like The full height of the sky ellipse in arcsec. Broadcast against ``width_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) of the ``skycoord`` frame, which may differ from the WCS celestial frame. pixcoord : tuple of float, optional The ``(x, y)`` pixel position of ``skycoord``, if already known. When given, the WCS is not inverted to find it. ``skycoord`` is then used only to name the position in an error message and to find the rotation between its frame and the WCS frame. It may be `None`, in which case ``sky_angle_rad`` is taken to be measured in the WCS frame. Returns ------- center : tuple of float The ``(x, y)`` pixel center position. pixel_width : float or `~numpy.ndarray` The full width of the pixel ellipse in pixels, with the broadcast shape of ``width_arcsec`` and ``height_arcsec``. pixel_height : float or `~numpy.ndarray` The full height of the pixel ellipse in pixels, with the broadcast shape of ``width_arcsec`` and ``height_arcsec``. 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, with the broadcast shape of ``width_arcsec`` and ``height_arcsec``. """ center, jacobian = _sky_to_pixel_jacobian(wcs, skycoord, pixcoord=pixcoord) # The Jacobian is defined in the WCS celestial frame, so rotate the # position angle from the skycoord frame into that frame. if skycoord is not None: sky_angle_rad = sky_angle_rad + _frame_rotation(wcs, skycoord) # Build M_sky: columns are sky semiaxis vectors in tangent-plane # coordinates (xi=East, eta=North). The width axis is at the given # PA from North (toward East), so its tangent-plane components are # (sin(PA), cos(PA)). The height axis is perpendicular, at PA+90. # The local Jacobian already encodes the WCS parity, so no manual # parity factor is applied here. width_arcsec = np.asarray(width_arcsec, dtype=float) height_arcsec = np.asarray(height_arcsec, dtype=float) shape = np.broadcast_shapes(width_arcsec.shape, height_arcsec.shape) cos_pa = np.cos(sky_angle_rad) sin_pa = np.sin(sky_angle_rad) half_w = np.broadcast_to(0.5 * width_arcsec, shape) half_h = np.broadcast_to(0.5 * height_arcsec, shape) m_sky = np.empty((*shape, 2, 2)) m_sky[..., 0, 0] = half_w * sin_pa m_sky[..., 0, 1] = half_h * cos_pa m_sky[..., 1, 0] = half_w * cos_pa m_sky[..., 1, 1] = -half_h * sin_pa # M_pix = J @ M_sky: columns are pixel semiaxis vectors m_pix = jacobian @ m_sky pixel_width, pixel_height, pixel_angle = _svd_ellipse_from_composite( m_pix, input_circular=np.isclose(width_arcsec, height_arcsec)) if not shape: return center, float(pixel_width), float(pixel_height), pixel_angle return center, pixel_width, pixel_height, pixel_angle def sky_to_pixel_svd_scales(wcs, skycoord, *, pixcoord=None): """ 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 image of the sky circle under the local linear mapping). 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`). skycoord : `~astropy.coordinates.SkyCoord` The sky coordinate of the region center. pixcoord : tuple of float, optional The ``(x, y)`` pixel position of ``skycoord``, if already known. When given, the WCS is not inverted to find it and ``skycoord`` is used only to name the position in an error message (it may be `None`). 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. The axis direction is defined only modulo 180 degrees. """ center, jacobian = _sky_to_pixel_jacobian(wcs, skycoord, pixcoord=pixcoord) 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 is needed because 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(wcs, pixcoord): """ Compute the principal-axis scale factors and sky angle for a pixel-to-sky conversion using SVD of the forward Jacobian. Uses the singular value decomposition (SVD) of the local forward Jacobian ``F = d(sky_arcsec)/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 image of the pixel circle under the local linear mapping). 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`). pixcoord : tuple of float The ``(x, y)`` pixel coordinate of the region center. Returns ------- 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. The axis direction is defined only modulo 180 degrees. """ forward = compute_pixel_to_sky_jacobians(wcs, pixcoord[0], pixcoord[1])[0] _check_jacobian_defined(forward, pixcoord) u_mat, s_vals, _vt = np.linalg.svd(forward) # Sky position angle (PA) of the major axis, measured from # North (eta/Dec) toward East (xi/RA). The columns of u_mat are # tangent-plane vectors with components (xi=East, eta=North), so the # PA is arctan2(xi, eta). The forward Jacobian already encodes the # WCS parity, so no manual parity correction is needed here. sky_angle = Angle( np.rad2deg(np.arctan2(u_mat[0, 0], u_mat[1, 0])) * u.deg, ).wrap_at(360 * u.deg) return s_vals[0], s_vals[1], sky_angle astropy-photutils-24be529/photutils/utils/colormaps.py000066400000000000000000000033031526452531300233530ustar00rootroot00000000000000# 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-24be529/photutils/utils/cutouts.py000066400000000000000000000273201526452531300230670ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for generating 2D image cutouts. """ from functools import cached_property import numpy as np from astropy.nddata import extract_array from photutils.utils._deprecation import deprecated_positional_kwargs from photutils.utils._overlap import overlap_slices from photutils.utils._round import round_half_away __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. The exception is a ``mode='partial'`` cutout that extends beyond the ``data`` array, which is always a new array. Parameters ---------- data : `~numpy.ndarray` The 2D data array from which to extract the cutout array. position : 2-tuple of int or float 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, nonoverlapping 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. A ``mode='partial'`` cutout that extends beyond the ``data`` array is always a new array. Attributes ---------- data : 2D `~numpy.ndarray` The cutout array. shape : tuple of 2 ints The shape of the cutout array. In ``'trim'`` mode it can be smaller than the requested ``shape``. 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) [[ 5. 6. 7.] [ 9. 10. 11.] [13. 14. 15.]] >>> cutout2 = CutoutImage(data, (0, 0), (3, 3), mode='partial') >>> print(cutout2.data) [[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})') @cached_property 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 (nonfilled) cutout values. """ return self._overlap_slices[0] @cached_property 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 (nonfilled) cutout values. """ return self._overlap_slices[1] def _calc_bbox(self, slices): """ Calculate the `~photutils.aperture.BoundingBox` for 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) @cached_property 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 (nonfilled) cutout values. """ return self._calc_bbox(self.slices_original) @cached_property 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 (nonfilled) 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)) @cached_property 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. For ``mode='partial'``, the origin index will be negative along an axis where the cutout extends beyond the lower edge of the original array. """ 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, with ties rounding half away from zero. 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 = round_half_away(ypos) xc = round_half_away(xpos) # 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-24be529/photutils/utils/depths.py000066400000000000000000000542521526452531300226540ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for calculating limiting fluxes. """ import warnings from copy import copy 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'] 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. The dilation radius is ``aper_radius`` plus ``mask_pad``, rounded up to the nearest integer. 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. If `False`, fewer than ``n_apertures`` apertures may be used when the unmasked area is too small, in which case a warning is issued (see the ``n_apertures_used`` attribute). overlap_maxiters : int, optional This keyword is unused and has no effect. .. deprecated:: 3.1 The ``overlap_maxiters`` keyword is deprecated and will be removed in version 4.0. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Each call to the object creates a fresh generator initialized with ``seed``, so all calls with the same ``seed`` generate identical 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. The default is a `~astropy.stats.SigmaClip` instance with ``sigma=3.0`` and ``maxiters=10``. 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``, rounded up to the nearest integer. 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 the flux limit using the input ``zeropoint`` keyword as: .. math:: m_{\mathrm{lim}} = -2.5 \log_{10} f_{\mathrm{lim}} + \mathrm{zeropoint} Instances are safe to call concurrently from multiple threads. Each call uses only local state, including a fresh random generator initialized with ``seed``. The result attributes (``apertures``, ``n_apertures_used``, ``fluxes``, ``flux_limits``, and ``mag_limits``) reflect the most recently completed call. A `ValueError` is raised if no unmasked pixels remain after dilating the mask and masking the border, or if ``n_apertures`` exceeds the number of unmasked pixels. A warning is issued if fewer than ``n_apertures`` nonoverlapping apertures could be placed, or if any flux limit is zero. Examples -------- >>> import numpy as np >>> from astropy.convolution import convolve >>> 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) >>> 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)) [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) 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) fig, ax = plt.subplots(nrows=2, 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') @deprecated_renamed_argument('overlap_maxiters', None, '3.1', 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) if n_apertures < 1: msg = 'n_apertures must be >= 1' raise ValueError(msg) if n_iters < 1: msg = 'n_iters must be >= 1' 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 # remove in 4.0 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.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', '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` or `~astropy.units.Quantity` 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, apart from the masked image border. Returns ------- flux_limit : float or `~astropy.units.Quantity` The flux limit, in the same units as the input ``data``. mag_limit : float The magnitude limit, calculated from the flux limit and the input ``zeropoint``. """ # Prevent circular import from photutils.aperture import AperturePhotometry, 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) # Use a fresh, local random generator so that calls use no # shared mutable state and are safe to run concurrently from # multiple threads. With a fixed seed, every call produces # identical results. rng = np.random.default_rng(self.seed) # Use a local copy of the SigmaClip instance because SigmaClip # stores internal state on the instance during calls, so a # shared instance is not safe to call concurrently from multiple # threads. sigma_clip = copy(self.sigma_clip) apertures = [] all_fluxes = [] flux_limits = [] for _ in iter_range: if self.overlap: xycoords = self._make_coords(all_xycoords, n_apertures, rng=rng) else: # Cut the number of coords (only need to input ~10x) xycoords = self._make_coords(all_xycoords, n_apertures * 10, rng=rng) 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 = AperturePhotometry(data, apers).flux if sigma_clip is not None: fluxes = sigma_clip(fluxes, masked=False) # ndarray all_fluxes.append(fluxes) flux_limits.append(self.n_sigma * np.std(fluxes)) n_apertures_used = np.array([len(apers) for apers in apertures]) if np.any(n_apertures_used < self.n_apertures): msg = (f'Unable to generate {self.n_apertures} ' 'nonoverlapping 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. Alternatively, you may set ' 'overlap=True') warnings.warn(msg, AstropyUserWarning) if isinstance(flux_limits[0], u.Quantity): units = True flux_limits = u.Quantity(flux_limits) else: units = False flux_limits = np.array(flux_limits) flux_limit = np.mean(flux_limits) if np.any(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_limit_values = flux_limits flux_limit_ = flux_limit if units: flux_limit_values = flux_limits.value flux_limit_ = flux_limit.value mag_limits = -2.5 * np.log10(flux_limit_values) + self.zeropoint mag_limit = -2.5 * np.log10(flux_limit_) + self.zeropoint # Store the per-iteration results computed above. The result # attributes reflect the most recently completed call. self.apertures = apertures self.n_apertures_used = n_apertures_used self.fluxes = all_fluxes self.flux_limits = flux_limits self.mag_limits = mag_limits 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 (0 or 1) that is collapsed. The returned slice is for the other axis. Returns ------- slice : slice object A slice object for the axis orthogonal to ``axis``. If the array is all `False`, then the slice object will include the entire range of that axis. """ 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``, rounded up to the nearest integer. 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 : Nx2 `~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 : Nx2 `~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)) @staticmethod def _make_coords(xycoords, n_apertures, *, rng): """ Randomly choose ``n_apertures`` (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 : Nx2 `~numpy.ndarray` The (x, y) coordinates. n_apertures : int The number of apertures to make. rng : `~numpy.random.Generator` The random number generator. Returns ------- xycoords : Nx2 `~numpy.ndarray` The (x, y) coordinates. """ if n_apertures > xycoords.shape[0]: msg = 'Too many apertures for given unmasked area' raise ValueError(msg) idx = rng.choice(xycoords.shape[0], n_apertures, replace=False) xycoords = xycoords[idx, :].astype(float) shift = rng.uniform(-0.5, 0.5, size=xycoords.shape) xycoords += shift return xycoords astropy-photutils-24be529/photutils/utils/errors.py000066400000000000000000000237321526452531300227000ustar00rootroot00000000000000# 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 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. The dtype follows the NumPy type promotion of the floating-point inputs (see Notes). 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 units of ``data * effective_gain`` must be electrons or photons (``astropy.units.electron`` or ``astropy.units.photon``). Otherwise, a `~astropy.units.UnitsError` is raised. 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``, an 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}}}} The dtype of the returned array follows the NumPy type promotion of the ``data`` and ``bkg_error`` arrays, and of ``effective_gain`` if it is an array, with a minimum of float32. For example, float32 ``data`` and ``bkg_error`` arrays give a float32 total error, while float32 ``data`` with a float64 ``bkg_error`` array gives a float64 total error. A scalar ``effective_gain`` does not affect the dtype. If any of the input arrays has a non-floating-point dtype (e.g., integer ``data``), the returned array is float64. The inputs are converted to the output dtype before the calculation. ``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 nonuniform 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) unit = None 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) unit = data.unit data = data.value bkg_error = bkg_error.value effective_gain = effective_gain.value scalar_gain = np.ndim(effective_gain) == 0 if not scalar_gain: 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 nonnegative everywhere' raise ValueError(msg) # The arrays are converted to the output dtype before any # arithmetic, so that the precision of the result is not limited by # a lower-precision input. arrays = [data, bkg_error] if not scalar_gain: arrays.append(effective_gain) dtype = _total_error_dtype(*arrays) # Do not include source variance where effective_gain = 0. # source_variance is a copy of data, which is not modified. source_variance = data.astype(dtype) if scalar_gain: if effective_gain == 0: source_variance[...] = 0.0 else: source_variance /= dtype.type(effective_gain) else: effective_gain = effective_gain.astype(dtype, copy=False) 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) np.maximum(source_variance, 0, out=source_variance) # source_variance has units of (data.unit)**2 so that it can be # added to bkg_error**2. The returned total error has the same units # as the data. total_error = source_variance total_error += bkg_error.astype(dtype, copy=False) ** 2 np.sqrt(total_error, out=total_error) if unit is not None: total_error <<= unit return total_error def _total_error_dtype(*arrays): """ Return the dtype of the total error calculated from the input arrays. Parameters ---------- *arrays : `~numpy.ndarray` The ``data`` and ``bkg_error`` arrays, and the ``effective_gain`` array if it is not a scalar. Returns ------- dtype : `numpy.dtype` The NumPy promotion of the input dtypes, with a minimum of float32, if all of the arrays are floating-point arrays. Otherwise, float64. """ if any(array.dtype.kind != 'f' for array in arrays): return np.dtype(np.float64) return np.result_type(np.float32, *[array.dtype for array in arrays]) astropy-photutils-24be529/photutils/utils/exceptions.py000066400000000000000000000016741526452531300235460ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Custom warning classes. """ from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning, AstropyWarning) __all__ = ['DeblendWarning', 'NoDetectionsWarning', 'PhotutilsDeprecationWarning'] class NoDetectionsWarning(AstropyWarning): """ A warning class to indicate no sources were detected. """ class DeblendWarning(AstropyUserWarning): """ A warning class to indicate issues encountered while deblending sources. """ class PhotutilsDeprecationWarning(AstropyDeprecationWarning): """ A warning class to indicate deprecated Photutils features. This is a subclass of `~astropy.utils.exceptions.AstropyDeprecationWarning`, so existing warning filters for that class continue to match. The class name identifies Photutils as the source of the warning. """ astropy-photutils-24be529/photutils/utils/footprints.py000066400000000000000000000030611526452531300235640ustar00rootroot00000000000000# 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-24be529/photutils/utils/interpolation.py000066400000000000000000000344201526452531300242470ustar00rootroot00000000000000# 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_getattr, deprecated_positional_kwargs, deprecated_renamed_argument) __all__ = ['ShepardIDWInterpolator'] # Remove in 4.0 _DEPRECATED_ATTRIBUTES = { 'ncoords': 'n_coords', } 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 the 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 nonnegative weights, if provided, are multiplied by the inverse distance weights. 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 : int, 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. Attributes ---------- coordinates : NxM `~numpy.ndarray` The coordinates of the known data points, where N is the number of points and M is the dimension of the coordinate space. ncoords : int .. deprecated:: 3.1 Use ``n_coords`` instead. n_coords : int The number of known data points. coords_ndim : int The dimension of the coordinate space. values : 1D `~numpy.ndarray` The values of the known data points. weights : 1D `~numpy.ndarray` or `None` The weights associated with each data value, or `None` if no weights were input. kdtree : `scipy.spatial.cKDTree` The k-d tree built from the input ``coordinates``. Notes ----- This interpolator uses a slightly modified version of `Shepard's method `_. The essential difference is the introduction of a "regularization" parameter (``regularization``) that is used when computing the inverse distance weights: .. math:: w_i = 1 / (d(x, x_i)^{power} + r) where :math:`r` is the ``regularization`` parameter. 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)) 0.38937843420912366 >>> float(np.sin(0.4)) 0.3894183423086505 >>> xi = rng.random(4) # 4 random values >>> xi array([0.47998792, 0.23237292, 0.80188058, 0.92353016]) >>> f(xi) array([0.46577097, 0.22837422, 0.71856662, 0.80125391]) >>> np.sin(xi) 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])) 0.8948257014687874 >>> float(np.sin(0.5 + 0.6)) 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() n_coords = coordinates.shape[0] if n_coords < 1: msg = 'coordinates must have at least one data point' raise ValueError(msg) if values.shape[0] != n_coords: 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] != n_coords: 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 nonnegative numbers.' raise ValueError(msg) self.coordinates = coordinates self.n_coords = n_coords self.coords_ndim = coordinates.shape[1] self.values = values self.weights = weights self.kdtree = cKDTree(coordinates, leafsize=leafsize) def __getattr__(self, name): return deprecated_getattr(self, name, _DEPRECATED_ATTRIBUTES, since='3.1', until='4.0') @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 the dimension of the coordinate space. When M=1 (1D space), ``positions`` may be a 1D array of N positions or a scalar for a single position. When M>1, a 1D array of length M is interpreted as a single position. ``positions`` may have at most 2 dimensions. .. 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 *k*-th 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 of the class docstring for more details. regularization : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. See the Notes section of the class docstring for more details. .. deprecated:: 3.0 The ``reg`` keyword is deprecated. Use ``regularization`` instead. conf_dist : float, optional The confusion distance at or 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. If `None` or nonpositive, the check is disabled. 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. Ignored when ``n_neighbors=1``, in which case the values of the nearest data points are returned unchanged. 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. NaN is returned where the total weight of the neighbors is zero. """ 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[0] 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 nonfinite 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-24be529/photutils/utils/tests/000077500000000000000000000000001526452531300221455ustar00rootroot00000000000000astropy-photutils-24be529/photutils/utils/tests/__init__.py000066400000000000000000000000001526452531300242440ustar00rootroot00000000000000astropy-photutils-24be529/photutils/utils/tests/conftest.py000066400000000000000000000055261526452531300243540ustar00rootroot00000000000000# 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 pytest from astropy.wcs import WCS from photutils.utils.tests.wcs_test_helpers import (WCS_CDELT_ARCSEC, WCS_CENTER, make_simple_wcs, make_sip_wcs) @pytest.fixture def simple_wcs(): """ Nondistorted TAN WCS aligned with the celestial axes. """ return make_simple_wcs(WCS_CENTER, WCS_CDELT_ARCSEC * u.arcsec, 20) @pytest.fixture def rotated_wcs(): """ Nondistorted 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. """ coeffs = {'A_2_0': 1e-7, 'A_0_2': 1e-7, 'B_2_0': 1e-7, 'B_0_2': 1e-7} return make_sip_wcs(coeffs=coeffs) @pytest.fixture def nonsquare_wcs(): """ Nondistorted TAN WCS with nonsquare 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 @pytest.fixture def swapped_wcs(): """ Nondistorted TAN WCS with the latitude axis first. The CD matrix maps the same sky footprint as ``simple_wcs`` (RA increasing to the left along x, Dec increasing along y), but the world axes are ordered (Dec, RA). The Jacobians and conversions must not depend on the world axis order. """ cdelt = WCS_CDELT_ARCSEC / 3600 wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [WCS_CENTER.dec.deg, WCS_CENTER.ra.deg] wcs.wcs.cd = [[0.0, cdelt], [-cdelt, 0.0]] wcs.wcs.ctype = ['DEC--TAN', 'RA---TAN'] return wcs @pytest.fixture def flipped_wcs(): """ Nondistorted TAN WCS with a flipped parity (North down, East left). Both CDELT values are negative, so the determinant of the pixel scale matrix is positive (parity = +1). This is the opposite parity from the standard astronomical convention (North up, East left) and reproduces the mirrored-aperture bug reported for such WCS. """ 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', 'DEC--TAN'] return wcs @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) astropy-photutils-24be529/photutils/utils/tests/test_colormaps.py000066400000000000000000000033351526452531300255610ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the colormaps module. """ import pytest from numpy.testing import assert_allclose from photutils.utils._optional_deps import HAS_MATPLOTLIB from photutils.utils.colormaps import make_random_cmap from photutils.utils.exceptions import PhotutilsDeprecationWarning @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(PhotutilsDeprecationWarning, match=match): cmap = make_random_cmap(ncolors=10, seed=0) assert len(cmap.colors) == 10 astropy-photutils-24be529/photutils/utils/tests/test_convolution.py000066400000000000000000000047751526452531300261520ustar00rootroot00000000000000# 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-24be529/photutils/utils/tests/test_coords.py000066400000000000000000000040241526452531300250470ustar00rootroot00000000000000# 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 n_coords = 100 xycoords = make_random_xycoords(n_coords, (xmin, xmax), (ymin, ymax), min_separation=min_sep, seed=0) assert xycoords.shape == (n_coords, 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-24be529/photutils/utils/tests/test_cutouts.py000066400000000000000000000250621526452531300252710ustar00rootroot00000000000000# 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 NoOverlapError, 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)) @pytest.mark.parametrize('shape', [(31, 31), [31, 31], np.array([31, 31])]) def test_cutout_edge_no_overlap(self, shape): """ Test that a cutout ending exactly at the lower edge of the data raises NoOverlapError for any array-like shape. """ data = np.zeros((100, 100)) match = 'Arrays do not overlap' for position in ((50, -16), (-16, 50)): with pytest.raises(NoOverlapError, match=match): CutoutImage(data, position, shape) 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_half_pixel_positions(self): """ Test that half-integer positions round half away from zero. """ xpos = np.array([2.5]) ypos = np.array([3.5]) cutouts, mask = _make_cutouts(self.data, xpos, ypos, (3, 3)) assert mask[0].all() # Centers round to (y, x) = (4, 3) expected = self.data[3:6, 2:5] np.testing.assert_array_equal(cutouts[0], expected) 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 give 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-24be529/photutils/utils/tests/test_deprecation.py000066400000000000000000001111371526452531300260570ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _deprecation module. """ import warnings from copy import deepcopy import numpy as np import pytest from astropy.table import QTable, Table, TableMergeError, join, unique from photutils._future_column_names import (_future_column_names_var, use_future_column_names) from photutils.utils._deprecation import (DeprecatedColumnQTable, DeprecatedColumnTable, create_deprecated_table_from_data, create_empty_deprecated_qtable, deprecated, deprecated_getattr, deprecated_positional_kwargs, deprecated_renamed_argument) from photutils.utils.exceptions import PhotutilsDeprecationWarning 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(PhotutilsDeprecationWarning, match=match): col = table['old'] assert np.all(col == table['new']) match = "'old_b' was deprecated" with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, match=match): del table1['old'] assert 'new' not in table1.colnames table2 = create_deprecated_table_from_data(raw_data, DEPRECATION_MAP) with pytest.warns(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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 nonstring 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(PhotutilsDeprecationWarning, match=match): assert 'old' in table # Nondeprecated column: no warning assert 'stable' in table # Missing column: no warning assert 'missing' not in table def test_deepcopy_preserves_deprecation(self, raw_data): """ Test that deepcopy() preserves deprecation behavior and copies the data and metadata. """ table = create_deprecated_table_from_data( raw_data, DEPRECATION_MAP, since='3.0', until='4.0') table.meta['key'] = [1, 2] copied = deepcopy(table) assert isinstance(copied, DeprecatedColumnTable) match = "'old' was deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match) as record: col = copied['old'] assert np.all(col == [3, 2, 1]) msg = str(record[0].message) assert 'in version 3.0' in msg assert 'version 4.0' in msg # Ensure the column data are deep-copied table['new'][0] = 999 assert copied['new'][0] == 3 # Ensure the metadata are deep-copied table.meta['key'].append(3) assert copied.meta['key'] == [1, 2] 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning): 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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 nonstring/nonsequence 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(PhotutilsDeprecationWarning, match=match): result = _example_func(1, 2) assert result == 23 def test_warning_message_versions(self): with pytest.warns(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning): _example_func2(1, b=5) with pytest.warns(PhotutilsDeprecationWarning): _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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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(PhotutilsDeprecationWarning) 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-24be529/photutils/utils/tests/test_depths.py000066400000000000000000000261751526452531300250600ustar00rootroot00000000000000# 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.stats import SigmaClip from astropy.tests.helper import assert_quantity_allclose from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.datasets import make_100gaussians_image from photutils.segmentation import SourceFinder, make_2dgaussian_kernel from photutils.utils.depths import ImageDepth from photutils.utils.exceptions import PhotutilsDeprecationWarning bool_vals = (True, False) 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) 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 .* nonoverlapping apertures' with pytest.warns(AstropyUserWarning, match=match): depth(self.data, mask) # Test for zero nonoverlapping 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 .* nonoverlapping 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 = 'n_apertures must be >= 1' with pytest.raises(ValueError, match=match): ImageDepth(4.0, n_sigma=5.0, n_apertures=0, n_iters=2, progress_bar=False) match = 'n_iters must be >= 1' with pytest.raises(ValueError, match=match): ImageDepth(4.0, n_sigma=5.0, n_apertures=500, n_iters=0, 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_repeated_calls(self): """ Test that repeated calls with a fixed seed produce identical results and do not accumulate state from previous calls. """ depth = ImageDepth(4, n_sigma=5.0, n_apertures=100, n_iters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) limits1 = depth(self.data, self.mask) fluxes1 = depth.fluxes apertures1 = depth.apertures limits2 = depth(self.data, self.mask) assert_allclose(limits2, limits1) # The result attributes reflect only the most recent call assert len(depth.fluxes) == 2 assert len(depth.apertures) == 2 assert len(depth.flux_limits) == 2 assert len(depth.mag_limits) == 2 assert len(depth.n_apertures_used) == 2 # A fresh per-call generator makes calls idempotent for fluxes_a, fluxes_b in zip(fluxes1, depth.fluxes, strict=True): assert_allclose(fluxes_a, fluxes_b) for apers_a, apers_b in zip(apertures1, depth.apertures, strict=True): assert_allclose(apers_a.positions, apers_b.positions) 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_overlap_maxiters_deprecation(self): """ Test that the unused overlap_maxiters keyword is deprecated. """ match = "'overlap_maxiters' was deprecated in version 3.1" with pytest.warns(PhotutilsDeprecationWarning, match=match): ImageDepth(4, overlap_maxiters=10) 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(PhotutilsDeprecationWarning, match=match): _ = depth.napers def test_user_sigma_clip_instance_not_called(self, monkeypatch): """ Test that the user's SigmaClip instance is never called directly (an internal copy must be used). astropy SigmaClip stores internal state on the instance during calls, so a shared instance is not safe to call concurrently from multiple threads. """ sigclip = SigmaClip(sigma=3.0, maxiters=5) depth = ImageDepth(4, n_sigma=5.0, n_apertures=50, n_iters=1, mask_pad=5, overlap=True, seed=123, sigma_clip=sigclip, progress_bar=False) called_ids = [] orig_call = SigmaClip.__call__ def spy(self, *args, **kwargs): called_ids.append(id(self)) return orig_call(self, *args, **kwargs) monkeypatch.setattr(SigmaClip, '__call__', spy) limits = depth(self.data, self.mask) assert np.isfinite(limits[0]) assert len(called_ids) > 0 # sigma clipping was exercised assert id(sigclip) not in called_ids astropy-photutils-24be529/photutils/utils/tests/test_errors.py000066400000000000000000000221721526452531300250760ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the errors module. """ import tracemalloc 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 nonnegative 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_integer_data(): """ Test calc_total_error with integer data and bkg_error inputs. """ data_int = (DATA * 5).astype(int) error_tot = calc_total_error(data_int, BKG_ERROR, 2.0) assert error_tot.dtype == float expected = calc_total_error(data_int.astype(float), BKG_ERROR, 2.0) assert_allclose(error_tot, expected) error_tot = calc_total_error(data_int, BKG_ERROR.astype(int), 2.0) assert_allclose(error_tot, expected) 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) def make_dtype_inputs(shape=(40, 50)): """ Make float64 data (with negative values), bkg_error, and effective_gain (with zero values) arrays. """ rng = np.random.default_rng(0) data = rng.normal(20.0, 15.0, shape) bkg_error = rng.uniform(1.0, 3.0, shape) effective_gain = rng.uniform(0.5, 4.0, shape) effective_gain[3, 4] = 0.0 effective_gain[10:12, 20:25] = 0.0 return data, bkg_error, effective_gain @pytest.mark.parametrize( ('data_dtype', 'bkg_dtype', 'gain_dtype', 'expected'), [(np.float32, np.float32, None, np.float32), (np.float32, np.float32, np.float32, np.float32), (np.float64, np.float64, None, np.float64), (np.float32, np.float64, None, np.float64), (np.float64, np.float32, None, np.float64), (np.float32, np.float32, np.float64, np.float64), (np.float16, np.float16, None, np.float32), (np.float16, np.float16, np.float16, np.float32), (np.float16, np.float32, None, np.float32), ('>f4', '>f4', None, np.float32), (np.int32, np.float32, None, np.float64), (np.float32, np.int16, None, np.float64), (np.float32, np.float32, np.int64, np.float64), (np.int16, np.int16, None, np.float64)]) def test_output_dtype(data_dtype, bkg_dtype, gain_dtype, expected): """ Test that the output dtype follows the NumPy promotion of the floating-point inputs, with a minimum of float32, and that integer inputs give float64. `None` for ``gain_dtype`` is a Python scalar effective_gain, which does not affect the output dtype. """ data, bkg_error, effective_gain = make_dtype_inputs() data = data.astype(data_dtype) bkg_error = bkg_error.astype(bkg_dtype) if gain_dtype is None: effective_gain = 2.0 else: effective_gain = np.ceil(effective_gain).astype(gain_dtype) effective_gain[0, 0] = 0 error_tot = calc_total_error(data, bkg_error, effective_gain) assert error_tot.dtype == np.dtype(expected) assert error_tot.dtype.isnative assert error_tot.shape == data.shape # The values match the float64 calculation to the output precision gain64 = (effective_gain if gain_dtype is None else effective_gain.astype(float)) error_ref = calc_total_error(data.astype(float), bkg_error.astype(float), gain64) assert error_ref.dtype == np.float64 rtol = 1e-6 if expected is np.float32 else 1e-14 assert_allclose(error_tot, error_ref, rtol=rtol) def test_output_dtype_numpy_scalar_gain(): """ Test that a NumPy scalar effective_gain does not affect the output dtype. """ data, bkg_error, _ = make_dtype_inputs() data = data.astype(np.float32) bkg_error = bkg_error.astype(np.float32) expected = calc_total_error(data, bkg_error, 2.0) for gain in (np.float64(2.0), np.float32(2.0), np.int64(2), np.array(2.0)): error_tot = calc_total_error(data, bkg_error, gain) assert error_tot.dtype == np.float32 assert_allclose(error_tot, expected, rtol=1e-6) def test_output_dtype_units(): """ Test that the output dtype is preserved for Quantity inputs. """ data, bkg_error, effective_gain = make_dtype_inputs() units = u.electron / u.s data = data.astype(np.float32) << units bkg_error = bkg_error.astype(np.float32) << units for gain in (2.0 * u.s, effective_gain.astype(np.float32) << u.s): error_tot = calc_total_error(data, bkg_error, gain) assert error_tot.unit == units assert error_tot.dtype == np.float32 expected = calc_total_error(data.value, bkg_error.value, gain.value) assert_allclose(error_tot.value, expected) def test_inputs_not_modified(): """ Test that the input arrays are not modified. """ data, bkg_error, effective_gain = make_dtype_inputs() for dtype in (np.float32, np.float64): inputs = [arr.astype(dtype) for arr in (data, bkg_error, effective_gain)] copies = [arr.copy() for arr in inputs] calc_total_error(*inputs) calc_total_error(inputs[0], inputs[1], 2.0) for arr, copy in zip(inputs, copies, strict=True): assert_allclose(arr, copy, rtol=0) def test_scalar_gain_memory(): """ Test that a scalar effective_gain is not expanded to a full-size float64 image. """ rng = np.random.default_rng(0) data = rng.normal(20.0, 15.0, (1024, 1024)).astype(np.float32) bkg_error = np.ones(data.shape, dtype=np.float32) tracemalloc.start() try: start = tracemalloc.get_traced_memory()[0] error_tot = calc_total_error(data, bkg_error, 2.0) peak = tracemalloc.get_traced_memory()[1] - start finally: tracemalloc.stop() assert error_tot.dtype == np.float32 # The float32 output needs 4 bytes per pixel. A float64 copy of the # data plus a float64 gain image would need 16. assert peak < 14 * data.size astropy-photutils-24be529/photutils/utils/tests/test_exceptions.py000066400000000000000000000025641526452531300257460ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the exceptions module. """ import warnings import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.utils import PhotutilsDeprecationWarning class TestPhotutilsDeprecationWarning: """ Tests for PhotutilsDeprecationWarning. """ def test_subclass(self): assert issubclass(PhotutilsDeprecationWarning, AstropyDeprecationWarning) def test_not_hidden_by_default(self): # Like AstropyDeprecationWarning, this is deliberately not a # DeprecationWarning subclass. Python hides those by default # outside of __main__. assert not issubclass(PhotutilsDeprecationWarning, DeprecationWarning) def test_module_path(self): # The module path is what pytest and the warnings module # display, so it must identify photutils. assert (PhotutilsDeprecationWarning.__module__ == 'photutils.utils.exceptions') def test_caught_by_astropy_filter(self): with warnings.catch_warnings(): warnings.simplefilter('error', AstropyDeprecationWarning) with pytest.raises(PhotutilsDeprecationWarning): warnings.warn('test', PhotutilsDeprecationWarning, stacklevel=1) astropy-photutils-24be529/photutils/utils/tests/test_flags.py000066400000000000000000000315161526452531300246600ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _flags module. """ from typing import ClassVar import numpy as np import pytest from photutils.utils._flags import (FlagDefinition, FlagRegistry, decode_flags, define_flag_docstring, update_flag_docstring) from photutils.utils.exceptions import PhotutilsDeprecationWarning class _ExampleFlags(FlagRegistry): """ A minimal flag registry for testing the shared machinery. """ FLAG_DEFINITIONS: ClassVar = [ FlagDefinition(1, 'one', 'first flag', 'The first flag.'), FlagDefinition(2, 'two', 'second flag', 'The second flag.'), FlagDefinition(4, 'four', 'third flag', 'The third flag.'), ] domain: ClassVar = 'example' _DEPRECATED_FLAG_NAMES: ClassVar = {'old_one': 'one', 'old_two': 'two'} _DEPRECATED_SINCE: ClassVar = {'old_one': '3.0', 'old_two': '3.1'} _DEPRECATED_UNTIL: ClassVar = '4.0' @pytest.fixture def registry(): """ Provide an example flag registry instance. """ return _ExampleFlags() class TestFlagRegistry: """ Tests for the FlagRegistry base class. """ def test_constants(self, registry): """ Test that uppercase constants are created for each flag. """ assert registry.ONE == 1 assert registry.TWO == 2 assert registry.FOUR == 4 def test_all_flags(self, registry): """ Test that all_flags returns a copy of the flag definitions. """ flags = registry.all_flags assert flags == _ExampleFlags.FLAG_DEFINITIONS flags.append(None) assert len(registry.all_flags) == 3 def test_bit_values(self, registry): """ Test the bit_values property. """ assert registry.bit_values == [1, 2, 4] def test_names(self, registry): """ Test the names property. """ assert registry.names == ['one', 'two', 'four'] def test_flag_dict(self, registry): """ Test the flag_dict property. """ assert registry.flag_dict == {1: 'one', 2: 'two', 4: 'four'} def test_get_definition_by_bit_value(self, registry): """ Test get_definition with int and numpy integer bit values. """ assert registry.get_definition(2).name == 'two' assert registry.get_definition(np.int64(4)).name == 'four' def test_get_definition_by_name(self, registry): """ Test get_definition with a flag name. """ definition = registry.get_definition('one') assert definition.bit_value == 1 assert definition.description == 'first flag' @pytest.mark.parametrize(('old_name', 'new_name', 'since'), [ ('old_one', 'one', '3.0'), ('old_two', 'two', '3.1'), ]) def test_get_definition_deprecated_name(self, registry, old_name, new_name, since): """ Test that a deprecated flag name warns with its own deprecation version and resolves to the new name. """ match = f"'{old_name}' is deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match) as record: definition = registry.get_definition(old_name) assert definition.name == new_name msg = str(record[0].message) assert f'version {since}' in msg assert 'version 4.0' in msg def test_get_definition_unknown_bit_value(self, registry): """ Test that an unknown bit value raises KeyError. """ match = 'No flag with bit value 8' with pytest.raises(KeyError, match=match): registry.get_definition(8) def test_get_definition_unknown_name(self, registry): """ Test that an unknown flag name raises KeyError. """ match = "No flag with name 'unknown'" with pytest.raises(KeyError, match=match): registry.get_definition('unknown') @pytest.mark.parametrize('identifier', [1.5, True, None, [1]]) def test_get_definition_invalid_type(self, registry, identifier): """ Test that non-int, non-str identifiers raise TypeError. """ match = 'identifier must be int' with pytest.raises(TypeError, match=match): registry.get_definition(identifier) def test_get_name(self, registry): """ Test get_name. """ assert registry.get_name(2) == 'two' def test_get_bit_value(self, registry): """ Test get_bit_value. """ assert registry.get_bit_value('two') == 2 def test_get_description(self, registry): """ Test get_description. """ assert registry.get_description(4) == 'third flag' def test_get_detailed_description(self, registry): """ Test get_detailed_description. """ assert registry.get_detailed_description(4) == 'The third flag.' def test_deprecated_constant(self, registry): """ Test that a deprecated constant name warns and resolves to the new constant. """ match = "'OLD_ONE' attribute was deprecated in version 3.0" with pytest.warns(PhotutilsDeprecationWarning, match=match): assert registry.OLD_ONE == 1 match = "'OLD_TWO' attribute was deprecated in version 3.1" with pytest.warns(PhotutilsDeprecationWarning, match=match): assert registry.OLD_TWO == 2 def test_deprecated_constant_names_derived(self, registry): """ Test that the deprecated constant names are the uppercase deprecated flag names. """ assert registry._DEPRECATED_CONSTANT_NAMES == {'OLD_ONE': 'ONE', 'OLD_TWO': 'TWO'} def test_missing_deprecated_since(self): """ Test that a registry with a deprecated flag name that has no deprecation version raises an error when the class is defined. """ match = ('_DEPRECATED_SINCE is missing the deprecated flag names ' r"\['old_two'\]") with pytest.raises(ValueError, match=match): class _BadFlags(FlagRegistry): FLAG_DEFINITIONS: ClassVar = [ FlagDefinition(1, 'one', 'first flag', 'The first flag.'), FlagDefinition(2, 'two', 'second flag', 'The second flag.'), ] domain: ClassVar = 'bad' _DEPRECATED_FLAG_NAMES: ClassVar = {'old_one': 'one', 'old_two': 'two'} _DEPRECATED_SINCE: ClassVar = {'old_one': '3.0'} def test_unknown_attribute(self, registry): """ Test that an unknown attribute raises AttributeError. """ match = "no attribute 'UNKNOWN'" with pytest.raises(AttributeError, match=match): _ = registry.UNKNOWN class TestDefineFlagDocstring: """ Tests for the define_flag_docstring function. """ def test_bullet_list(self, registry): """ Test the generated bullet list content. """ lines = define_flag_docstring(registry) assert lines[0] == '' assert lines[1] == '* **0** : No flags set.' assert lines[2] == "* **1** (``'one'``) : The first flag." assert len(lines) == 5 def test_indent(self, registry): """ Test the indent keyword. """ lines = define_flag_docstring(registry, indent=4) assert lines[1] == ' * **0** : No flags set.' def test_invalid_registry(self): """ Test that a non-FlagRegistry input raises TypeError. """ match = 'registry must be an instance of FlagRegistry' with pytest.raises(TypeError, match=match): define_flag_docstring('not_a_registry') class TestUpdateFlagDocstring: """ Tests for the update_flag_docstring function. """ def test_placeholder_replaced(self, registry): """ Test that the placeholder is replaced with the bullet list. """ def func(): """ Flags. """ func = update_flag_docstring(func, registry) assert '' not in func.__doc__ assert "* **1** (``'one'``) : The first flag." in func.__doc__ def test_placeholder_indent_inferred(self, registry): """ Test that indent=None applies the placeholder indentation. """ docstring = 'Flags:\n\n \n\nMore text.\n' def func(): pass func.__doc__ = docstring func = update_flag_docstring(func, registry, indent=None) expected = ('Flags:\n\n\n' ' * **0** : No flags set.\n' " * **1** (``'one'``) : The first flag.\n") assert func.__doc__.startswith(expected) assert func.__doc__.endswith('\n\nMore text.\n') def test_placeholder_indent_inferred_not_alone(self, registry): """ Test that indent=None ignores a placeholder that is not alone on its line. """ docstring = 'The flags are here.\n' def func(): pass func.__doc__ = docstring func = update_flag_docstring(func, registry, indent=None) assert func.__doc__ == docstring def test_no_placeholder(self, registry): """ Test that a docstring without the placeholder is unchanged. """ def func(): """ Flags. """ docstring = func.__doc__ func = update_flag_docstring(func, registry) assert func.__doc__ == docstring def test_no_docstring(self, registry): """ Test that a function without a docstring is returned unchanged. """ def func(): pass func = update_flag_docstring(func, registry) assert func.__doc__ is None class TestDecodeFlags: """ Tests for the decode_flags function. """ def test_scalar_zero(self, registry): """ Test that a zero flag value decodes to an empty list. """ assert decode_flags(0, registry) == [] def test_scalar(self, registry): """ Test decoding scalar flag values. """ assert decode_flags(1, registry) == ['one'] assert decode_flags(5, registry) == ['one', 'four'] assert decode_flags(7, registry) == ['one', 'two', 'four'] def test_return_bit_values(self, registry): """ Test the return_bit_values keyword. """ assert decode_flags(5, registry, return_bit_values=True) == [1, 4] def test_0d_array(self, registry): """ Test decoding a 0-d array flag value. """ assert decode_flags(np.array(3), registry) == ['one', 'two'] def test_1d_array(self, registry): """ Test decoding a 1D array of flag values. """ decoded = decode_flags(np.array([0, 1, 6]), registry) assert decoded == [[], ['one'], ['two', 'four']] def test_2d_array(self, registry): """ Test that decoding a 2D array preserves the input shape as nested lists. """ flags = np.array([[0, 1], [2, 5]]) decoded = decode_flags(flags, registry) assert decoded == [[[], ['one']], [['two'], ['one', 'four']]] @pytest.mark.parametrize('dtype', [np.uint8, np.int8, np.uint16]) def test_small_integer_dtype(self, dtype): """ Test that flags with a small integer dtype are decoded against a registry whose bit values do not fit in that dtype. """ class _WideFlags(FlagRegistry): FLAG_DEFINITIONS: ClassVar = [ FlagDefinition(1, 'one', 'first flag', 'The first flag.'), FlagDefinition(65536, 'wide', 'wide flag', 'The wide flag.'), ] domain: ClassVar = 'example' flags = np.array([0, 1], dtype=dtype) assert decode_flags(flags, _WideFlags()) == [[], ['one']] assert decode_flags(flags[1], _WideFlags()) == ['one'] def test_negative_flag(self, registry): """ Test that a negative flag value raises ValueError. """ match = 'Flag value must be a nonnegative integer' with pytest.raises(ValueError, match=match): decode_flags(-1, registry) @pytest.mark.parametrize('flag_value', [1.5, True, np.True_]) def test_invalid_type(self, registry, flag_value): """ Test that noninteger flag values (including bools) raise TypeError. """ match = 'Flag value must be an integer' with pytest.raises(TypeError, match=match): decode_flags(flag_value, registry) astropy-photutils-24be529/photutils/utils/tests/test_footprints.py000066400000000000000000000024021526452531300257630ustar00rootroot00000000000000# 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-24be529/photutils/utils/tests/test_interpolation.py000066400000000000000000000205161526452531300264510ustar00rootroot00000000000000# 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 from photutils.utils.exceptions import PhotutilsDeprecationWarning 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) @pytest.mark.parametrize(('coordinates', 'n_coords', 'coords_ndim'), [(np.arange(4.0), 4, 1), (np.zeros((7, 2)), 7, 2), (np.zeros((3, 4, 5)), 12, 5)]) def test_attributes(self, coordinates, n_coords, coords_ndim): """ Test the number and dimension of the input coordinates. """ values = np.ones(n_coords) f = IDWInterp(coordinates, values) assert f.n_coords == n_coords assert f.coords_ndim == coords_ndim def test_deprecated_attribute(self): """ Test the deprecated ncoords attribute. """ match = "The 'ncoords' attribute was deprecated" with pytest.warns(PhotutilsDeprecationWarning, match=match): assert self.f.ncoords == self.f.n_coords def test_invalid_attribute(self): """ Test that an unknown attribute raises AttributeError. """ match = "object has no attribute 'invalid'" with pytest.raises(AttributeError, match=match): _ = self.f.invalid 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 nonnegative 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_scalar_return_type(self): """ Test that single-position calls return the same scalar type for n_neighbors=1 and n_neighbors>1. """ result1 = self.f(0.5, n_neighbors=1) result8 = self.f(0.5, n_neighbors=8) assert np.isscalar(result1) assert np.isscalar(result8) assert type(result1) is type(result8) 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 nonfinite (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-24be529/photutils/utils/tests/test_misc.py000066400000000000000000000033641526452531300245170ustar00rootroot00000000000000# 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', 'matplotlib', 'gwcs', 'bottleneck') for key in keys: assert key in versions astropy-photutils-24be529/photutils/utils/tests/test_moments.py000066400000000000000000001121071526452531300252420ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _moments module. """ import warnings import numpy as np import pytest from astropy.wcs import WCS from numpy.testing import assert_allclose, assert_equal from photutils.utils._moments import (PIXEL_VARIANCE, PSD_RTOL, centroid_from_moments, covariance_determinant, covariance_from_moments, covariance_max_eigval, covariance_min_eigval, eigvals_from_covariance, floor_covariance_eigvals, image_moments, inertia_tensor_from_moments, is_invalid_covariance, is_singular_covariance, major_axis_angle, orientation_from_covariance, pixel_to_sky_covariance, regularize_covariance, sky_orientation_from_covariance) @pytest.fixture(autouse=True) def warnings_as_errors(): """ Turn every warning into an error for each test in this module. The helpers must not emit a warning for zero-flux or nonfinite inputs. This fixture checks that without relying on the pytest warnings plugin, which some CI jobs disable. """ with warnings.catch_warnings(): warnings.simplefilter('error') yield @pytest.fixture def moments_central(): """ Central moments for four sources with a leading source axis. Index ``[:, i, j]`` is the sum of ``y**i * x**j``. The sources are: 0. An axis-aligned ellipse with x variance 4 and y variance 1. 1. A unit-variance source with covariance 0.5 (45 degree tilt). 2. A source with zero total flux (undefined covariance). 3. A point-like source with zero second moments. """ moments = np.zeros((4, 3, 3)) moments[0, 0, 0] = 2.0 moments[0, 0, 2] = 8.0 moments[0, 2, 0] = 2.0 moments[1, 0, 0] = 1.0 moments[1, 0, 2] = 1.0 moments[1, 2, 0] = 1.0 moments[1, 1, 1] = 0.5 moments[2, 0, 2] = 1.0 moments[3, 0, 0] = 1.0 return moments @pytest.fixture def covariances(): """ Raw ``(N, 2, 2)`` covariance matrices covering the shape cases. 0. Resolved axis-aligned ellipse (det 4, min eigenvalue 1). 1. Point-like, all zeros (det 0, min eigenvalue 0). 2. Rank-1 degenerate along x (det 0, min eigenvalue 0). 3. Elongated with one unresolved axis (det 0.2, min eigenvalue 0.05, which is below 1/12 while det is above (1/12)**2). 4. Not positive semidefinite (det -3, min eigenvalue -1). 5. Partially nonfinite. """ return np.array([[[4.0, 0.0], [0.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[4.0, 0.0], [0.0, 0.0]], [[4.0, 0.0], [0.0, 0.05]], [[1.0, 2.0], [2.0, 1.0]], [[np.nan, 0.0], [0.0, 1.0]]]) @pytest.fixture def tan_wcs(): """ A TAN WCS with a 1 arcsec pixel scale, North up and East left. The forward Jacobian at the reference pixel maps ``+x`` to ``-East`` and ``+y`` to ``+North``. """ wcs = WCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = [50.0, 50.0] wcs.wcs.crval = [10.0, 0.0] wcs.wcs.cdelt = [-1.0 / 3600.0, 1.0 / 3600.0] return wcs class TestImageMoments: """ Tests for image_moments. """ def test_raw(self): """ Test the raw moments of a simple 2x2 array. """ 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_central(self): """ Test the central moments about the centroid. """ data = np.array([[0, 1], [0, 1]]) moments = image_moments(data, center=(1.0, 0.5), 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_central_nonsquare(self): """ Test the central moments of a nonsquare array. """ data = np.array([[0, 1], [0, 1], [0, 1]]) moments = image_moments(data, center=(1.0, 1.0), 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_invalid_dim(self): """ Test that 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_negative_order(self): """ Test that a negative order raises ValueError. """ data = np.array([[0, 1], [0, 1]]) match = 'order must be nonnegative' with pytest.raises(ValueError, match=match): image_moments(data, order=-1) class TestCentroidFromMoments: """ Tests for centroid_from_moments. """ def test_values(self): """ Test the (x, y) order against raw image moments. """ data = np.zeros((5, 7)) data[1, 4] = 1.0 data[3, 4] = 3.0 moments = np.array([image_moments(data, order=1)]) centroid = centroid_from_moments(moments) assert centroid.shape == (1, 2) assert_allclose(centroid, [[4.0, 2.5]]) def test_zero_flux(self): """ Test that zero total flux gives NaN without a warning. """ centroid = centroid_from_moments(np.zeros((2, 2, 2))) assert centroid.shape == (2, 2) assert np.all(np.isnan(centroid)) class TestInertiaTensorFromMoments: """ Tests for inertia_tensor_from_moments. """ def test_values(self, moments_central): """ Test the element layout and the sign of the cross term. """ tensor = inertia_tensor_from_moments(moments_central) assert tensor.shape == (4, 2, 2) assert_allclose(tensor[0], [[8.0, 0.0], [0.0, 2.0]]) assert_allclose(tensor[1], [[1.0, -0.5], [-0.5, 1.0]]) assert_allclose(tensor[2], [[1.0, 0.0], [0.0, 0.0]]) assert_allclose(tensor[3], [[0.0, 0.0], [0.0, 0.0]]) class TestCovarianceFromMoments: """ Tests for covariance_from_moments. """ def test_values(self, moments_central): """ Test the element layout and the flux normalization. """ covar = covariance_from_moments(moments_central) assert covar.shape == (4, 2, 2) assert_allclose(covar[0], [[4.0, 0.0], [0.0, 1.0]]) assert_allclose(covar[1], [[1.0, 0.5], [0.5, 1.0]]) assert_allclose(covar[3], [[0.0, 0.0], [0.0, 0.0]]) assert_equal(covar[:, 0, 1], covar[:, 1, 0]) def test_zero_flux(self, moments_central): """ Test that zero total flux is nonfinite without a warning. """ covar = covariance_from_moments(moments_central) assert not np.any(np.isfinite(covar[2])) def test_higher_order_moments_ignored(self, moments_central): """ Test that third-order and higher moments are not used. """ moments = np.full((4, 5, 5), 99.0) moments[:, :3, :3] = moments_central assert_equal(covariance_from_moments(moments), covariance_from_moments(moments_central)) def test_covariance_determinant(covariances): """ Test the determinants, including a NaN matrix without a warning. """ det = covariance_determinant(covariances) assert det.shape == (6,) assert_allclose(det[:5], [4.0, 0.0, 0.0, 0.2, -3.0]) assert np.isnan(det[5]) @pytest.mark.parametrize('index', [(0, 0), (0, 1), (1, 0), (1, 1)]) def test_covariance_determinant_nan(index): """ Test that a NaN in any element gives a NaN determinant. The zero elements make the matrix singular if the NaN is ignored, which is how a pivoted LU determinant can return zero. """ covar = np.array([[[1.0, 0.0], [0.0, 1.0]]]) covar[0][index] = np.nan assert np.isnan(covariance_determinant(covar)[0]) covar = np.full((1, 2, 2), np.nan) assert np.isnan(covariance_determinant(covar)[0]) class TestCovarianceMinEigval: """ Tests for covariance_min_eigval. """ def test_values(self, covariances): """ Test the closed-form smaller eigenvalue. """ det = covariance_determinant(covariances) min_eig = covariance_min_eigval(covariances, determinant=det) assert min_eig.shape == (6,) assert_allclose(min_eig[:5], [1.0, 0.0, 0.0, 0.05, -1.0], atol=1e-15) assert np.isnan(min_eig[5]) def test_matches_eigvalsh(self): """ Test the closed form against numpy for random matrices. """ rng = np.random.default_rng(0) arr = rng.normal(size=(20, 2, 2)) covar = arr @ arr.swapaxes(1, 2) # symmetric expected = np.linalg.eigvalsh(covar)[:, 0] det = covariance_determinant(covar) min_eig = covariance_min_eigval(covar, determinant=det) assert_allclose(min_eig, expected) def test_nearly_isotropic(self): """ Test nearly isotropic matrices, whose two eigenvalues differ by about the rounding error of the matrix elements. """ rng = np.random.default_rng(2) var_x = rng.uniform(0.01, 100.0, size=1000) var_y = var_x * (1.0 + rng.uniform(-1e-9, 1e-9, size=1000)) covar = np.zeros((1000, 2, 2)) covar[:, 0, 0] = var_x covar[:, 1, 1] = var_y det = covariance_determinant(covar) min_eig = covariance_min_eigval(covar, determinant=det) assert_allclose(min_eig, np.minimum(var_x, var_y), rtol=1e-7) def test_non_positive_trace(self): """ Test matrices with a zero or negative trace. """ covar = np.array([[[0.0, 0.0], [0.0, 0.0]], [[-1.0, 0.0], [0.0, -2.0]], [[-2.0, 1.0], [1.0, -2.0]], [[1.0, 0.0], [0.0, -1.0]]]) det = covariance_determinant(covar) min_eig = covariance_min_eigval(covar, determinant=det) assert_allclose(min_eig, [0.0, -2.0, -3.0, -1.0], atol=1e-15) @pytest.mark.parametrize('major', [1e6, 1e10, 1e14, 1e16]) def test_highly_elongated(self, major): """ Test that a large major-axis variance does not degrade the minor-axis variance through cancellation. """ covar = np.array([[[major, 0.0], [0.0, 0.1]]]) det = covariance_determinant(covar) min_eig = covariance_min_eigval(covar, determinant=det) assert_allclose(min_eig, [0.1], rtol=1e-12) def test_no_overflow(self): """ Test that a variance whose square overflows gives the correct minor-axis variance. """ covar = np.array([[[1e200, 0.0], [0.0, 0.01]]]) det = covariance_determinant(covar) min_eig = covariance_min_eigval(covar, determinant=det) assert_allclose(min_eig, [0.01], rtol=1e-12) class TestCovarianceMaxEigval: """ Tests for covariance_max_eigval. """ def test_values(self, covariances): """ Test the closed-form larger eigenvalue, including NaN propagation. """ eig_max = covariance_max_eigval(covariances) assert eig_max.shape == (6,) assert_allclose(eig_max[:5], [4.0, 0.0, 4.0, 4.0, 3.0]) assert np.isnan(eig_max[5]) def test_matches_eigvalsh(self): """ Test random symmetric matrices against np.linalg.eigvalsh. """ rng = np.random.default_rng(0) arr = rng.normal(size=(200, 2, 2)) covar = arr @ arr.transpose(0, 2, 1) expected = np.linalg.eigvalsh(covar)[:, 1] assert_allclose(covariance_max_eigval(covar), expected, rtol=1e-13) def test_no_overflow(self): """ Test that huge variances do not overflow. """ covar = np.array([[[1e300, 0.0], [0.0, 1e290]]]) assert_allclose(covariance_max_eigval(covar), 1e300) class TestIsSingularCovariance: """ Tests for is_singular_covariance. """ def test_values(self, covariances): """ Test each shape case. The elongated source 3 has a determinant above ``(1/12)**2`` and is flagged only by its minor-axis variance. The source 4 is not positive semidefinite, so it is invalid instead of singular. The partially nonfinite source 5 is not flagged. """ det = covariance_determinant(covariances) mask = is_singular_covariance(covariances, determinant=det) assert mask.dtype == bool assert_equal(mask, [False, True, True, True, False, False]) def test_threshold(self): """ Test that the threshold is the single-pixel variance. """ covar = np.array([[[4.0, 0.0], [0.0, PIXEL_VARIANCE - 1e-9]], [[4.0, 0.0], [0.0, PIXEL_VARIANCE + 1e-9]]]) det = covariance_determinant(covar) mask = is_singular_covariance(covar, determinant=det) assert_equal(mask, [True, False]) def test_includes_determinant_test(self): """ Test that every symmetric matrix with a determinant below ``PIXEL_VARIANCE**2`` is flagged as singular or as invalid, and never as both. """ rng = np.random.default_rng(3) covar = rng.normal(scale=0.3, size=(5000, 2, 2)) covar[:, 1, 0] = covar[:, 0, 1] det = covariance_determinant(covar) small_det = det < PIXEL_VARIANCE**2 assert 0 < np.count_nonzero(small_det) < len(det) singular = is_singular_covariance(covar, determinant=det) invalid = is_invalid_covariance(covar, determinant=det) assert np.any(singular) assert np.any(invalid) assert not np.any(singular & invalid) assert np.all((singular | invalid)[small_det]) class TestIsInvalidCovariance: """ Tests for is_invalid_covariance. """ def test_values(self, covariances): """ Test that only the source 4, which is not positive semidefinite, is flagged. The singular sources 1 to 3 are valid, and the partially nonfinite source 5 is not flagged. """ det = covariance_determinant(covariances) mask = is_invalid_covariance(covariances, determinant=det) assert mask.dtype == bool assert_equal(mask, [False, False, False, False, True, False]) def test_negative_trace(self): """ Test that a negative definite matrix, whose determinant is positive, is flagged by its trace. """ covar = np.array([[[-1.0, 0.0], [0.0, -2.0]]]) det = covariance_determinant(covar) assert det[0] > 0 assert_equal(is_invalid_covariance(covar, determinant=det), [True]) def test_infinite_determinant(self): """ Test that a matrix that is not positive semidefinite is flagged as invalid, and not as singular, when its determinant overflows to negative infinity. """ covar = np.array([[[1e200, 1e200], [1e200, -1e200]]]) det = covariance_determinant(covar) assert det[0] == -np.inf assert_equal(is_invalid_covariance(covar, determinant=det), [True]) assert_equal(is_singular_covariance(covar, determinant=det), [False]) def test_rank_one_rounding(self): """ Test that an exactly thin tilted matrix is valid even when its computed determinant is slightly negative. """ rng = np.random.default_rng(5) vec = rng.normal(size=(2000, 2)) * rng.uniform(0.1, 1e3, (2000, 1)) covar = vec[:, :, np.newaxis] * vec[:, np.newaxis, :] det = covariance_determinant(covar) assert np.any(det < 0) assert not np.any(is_invalid_covariance(covar, determinant=det)) assert np.all(is_singular_covariance(covar, determinant=det)) def test_matches_regularized_nan(self): """ Test that the invalid matrices are exactly the finite ones that regularize_covariance sets to NaN. """ rng = np.random.default_rng(6) covar = rng.normal(scale=0.5, size=(5000, 2, 2)) covar[:, 1, 0] = covar[:, 0, 1] det = covariance_determinant(covar) invalid = is_invalid_covariance(covar, determinant=det) reg = regularize_covariance(covar, determinant=det) assert 0 < np.count_nonzero(invalid) < len(det) assert_equal(np.isnan(reg).all(axis=(1, 2)), invalid) class TestFloorCovarianceEigvals: """ Tests for floor_covariance_eigvals. """ def test_scalar_minimum(self): """ Test that only an eigenvalue below the minimum is raised. """ covar = np.array([[[4.0, 0.0], [0.0, 0.05]], [[0.0, 0.0], [0.0, 0.0]]]) floored = floor_covariance_eigvals(covar, minimum=0.1) assert_allclose(floored[0], [[4.0, 0.0], [0.0, 0.1]]) assert_allclose(floored[1], 0.1 * np.eye(2)) def test_array_minimum(self): """ Test a separate minimum for each matrix. """ covar = np.array([[[4.0, 0.0], [0.0, 0.05]], [[4.0, 0.0], [0.0, 0.05]]]) floored = floor_covariance_eigvals(covar, minimum=np.array([0.01, 0.2])) assert_equal(floored[0], covar[0]) assert_allclose(floored[1], [[4.0, 0.0], [0.0, 0.2]]) def test_tilted(self): """ Test that the eigenvectors of a tilted matrix are kept. """ covar = np.array([[[2.0, 1.95], [1.95, 2.0]]]) floored = floor_covariance_eigvals(covar, minimum=0.5) assert_allclose(eigvals_from_covariance(floored), [[3.95, 0.5]]) assert_allclose(orientation_from_covariance(floored), [45.0]) assert_equal(floored[:, 0, 1], floored[:, 1, 0]) @pytest.mark.parametrize('angle', [10.0, 30.0, 45.0, 77.0]) def test_both_below_minimum(self, angle): """ Test that a tilted matrix with both eigenvalues below the minimum becomes exactly isotropic, with no spurious orientation from rounding. """ theta = np.deg2rad(angle) rot = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]) covar = (rot @ np.diag([0.05, 0.02]) @ rot.T)[np.newaxis] floored = floor_covariance_eigvals(covar, minimum=0.1) assert_equal(floored[0], 0.1 * np.eye(2)) assert_equal(orientation_from_covariance(floored), [0.0]) def test_floor_precision(self): """ Test that a floored eigenvalue equals the minimum to within the rounding error of the matrix elements, which scales with the larger eigenvalue. """ rng = np.random.default_rng(2) n_matrices = 1000 theta = rng.uniform(0.0, np.pi, n_matrices) eig_max = 10.0**rng.uniform(-0.5, 4.0, n_matrices) eig_min = rng.uniform(0.0, PIXEL_VARIANCE, n_matrices) cos, sin = np.cos(theta), np.sin(theta) rot = np.stack([np.stack([cos, -sin], axis=-1), np.stack([sin, cos], axis=-1)], axis=-2) eigvals = np.stack([eig_max, eig_min], axis=-1) covar = np.einsum('nij,nj,nkj->nik', rot, eigvals, rot) covar[:, 1, 0] = covar[:, 0, 1] floored = floor_covariance_eigvals(covar, minimum=PIXEL_VARIANCE) result = eigvals_from_covariance(floored) tol = 10.0 * np.finfo(float).eps * eig_max assert np.all(np.abs(result[:, 1] - PIXEL_VARIANCE) <= tol) assert_allclose(result[:, 0], eig_max, rtol=1e-14) @pytest.mark.parametrize('covar', [ [[1e200, 0.0], [0.0, 1e200]], [[1e308, 0.0], [0.0, 1e308]], [[1e308, 9e307], [9e307, 1e308]]]) def test_overflow_unchanged(self, covar): """ Test that a resolved matrix is returned unchanged, without a warning, when its determinant or its trace overflows. """ covar = np.array([covar]) floored = floor_covariance_eigvals(covar, minimum=PIXEL_VARIANCE) assert_equal(floored, covar) @pytest.mark.parametrize('covar', [ [[np.nan, 0.0], [0.0, 1.0]], [[np.inf, 0.0], [0.0, 0.01]], [[0.01, np.inf], [np.inf, 0.01]], [[0.01, 0.0], [0.0, np.nan]], [[0.0, np.inf], [np.inf, 0.0]], [[0.0, -np.inf], [-np.inf, -1.0]], [[-np.inf, 0.0], [0.0, 1.0]]]) def test_non_finite_unchanged(self, covar): """ Test that a matrix with a nonfinite element is returned unchanged, without a warning. An infinite off-diagonal element with a nonpositive trace gives eigenvalues of negative and positive infinity. """ covar = np.array([covar, [[1.0, 0.0], [0.0, 0.01]]]) floored = floor_covariance_eigvals(covar, minimum=PIXEL_VARIANCE) assert_equal(floored[0], covar[0]) assert_allclose(floored[1], [[1.0, 0.0], [0.0, PIXEL_VARIANCE]]) @pytest.mark.parametrize('covar', [ [[1e308, 0.0], [0.0, -1e308]], [[0.0, 1e308], [1e308, 0.0]]]) def test_overflow_indefinite(self, covar): """ Test that a huge matrix that is not positive semidefinite, whose eigenvalue difference overflows, does not emit a warning and is set to NaN by the regularization. """ covar = np.array([covar]) floored = floor_covariance_eigvals(covar, minimum=PIXEL_VARIANCE) assert floored.shape == covar.shape det = covariance_determinant(covar) reg = regularize_covariance(covar, determinant=det) assert np.all(np.isnan(reg)) def test_overflow_thin(self): """ Test that only the unresolved axis is raised when the square of the other variance overflows. """ covar = np.array([[[1e200, 0.0], [0.0, 0.01]]]) floored = floor_covariance_eigvals(covar, minimum=PIXEL_VARIANCE) assert_allclose(floored, [[[1e200, 0.0], [0.0, PIXEL_VARIANCE]]], rtol=1e-12) def test_unchanged_and_not_modified(self): """ Test that a matrix above the minimum is returned bit for bit and that the input array is left untouched. """ covar = np.array([[[2.0, 0.3], [0.3, 1.0]], [[2.0, 0.3], [0.3, 0.04]]]) original = covar.copy() floored = floor_covariance_eigvals(covar, minimum=0.1) assert floored is not covar assert_equal(covar, original) assert_equal(floored[0], covar[0]) assert not np.array_equal(floored[1], covar[1]) def test_empty(self): """ Test an input with no matrices. """ floored = floor_covariance_eigvals(np.empty((0, 2, 2)), minimum=0.1) assert floored.shape == (0, 2, 2) def regularize(covariance): """ Regularize covariance matrices with the determinant computed from the same matrices. """ det = covariance_determinant(covariance) return regularize_covariance(covariance, determinant=det) class TestRegularizeCovariance: """ Tests for regularize_covariance. """ def test_values(self, covariances): """ Test each shape case. Only an eigenvalue below the single-pixel variance is raised. The resolved major axis of sources 2 and 3 is unchanged. """ reg = regularize(covariances) assert_equal(reg[0], covariances[0]) assert_allclose(reg[1], PIXEL_VARIANCE * np.eye(2)) assert_allclose(reg[2], [[4.0, 0.0], [0.0, PIXEL_VARIANCE]]) assert_allclose(reg[3], [[4.0, 0.0], [0.0, PIXEL_VARIANCE]]) assert np.all(np.isnan(reg[4])) assert_equal(reg[5], covariances[5]) def test_tilted(self): """ Test that the orientation and the resolved eigenvalue of a tilted thin source are preserved. """ covar = np.array([[[2.0, 1.95], [1.95, 2.0]]]) reg = regularize(covar) assert_allclose(eigvals_from_covariance(reg), [[3.95, PIXEL_VARIANCE]]) assert_allclose(orientation_from_covariance(reg), [45.0]) assert_equal(reg[:, 0, 1], reg[:, 1, 0]) def test_continuous_at_threshold(self): """ Test that the regularized matrix is continuous where the minor-axis variance crosses the single-pixel variance. """ covar = np.array([[[4.0, 0.0], [0.0, PIXEL_VARIANCE - 1e-9]], [[4.0, 0.0], [0.0, PIXEL_VARIANCE + 1e-9]]]) reg = regularize(covar) assert_allclose(reg[0], reg[1], atol=2e-9) def test_rank_one_rounding(self): """ Test that an exactly thin tilted source is regularized when rounding makes its computed determinant slightly negative. The determinant of a rank-1 matrix is zero. A slightly negative value is passed explicitly because whether rounding produces one depends on the matrix elements. """ covar = np.array([[[0.25, -0.25], [-0.25, 0.25]]]) det = np.array([-1.0e-17]) mask = is_singular_covariance(covar, determinant=det) assert_equal(mask, [True]) reg = regularize_covariance(covar, determinant=det) assert_allclose(eigvals_from_covariance(reg), [[0.5, PIXEL_VARIANCE]]) assert_allclose(orientation_from_covariance(reg), [-45.0]) def test_negative_determinant(self): """ Test that a determinant that is negative beyond rounding still gives NaN. """ covar = np.array([[[0.25, -0.25], [-0.25, 0.25]]]) det = np.array([-1.0e-9]) reg = regularize_covariance(covar, determinant=det) assert np.all(np.isnan(reg)) @pytest.mark.parametrize('scale', [1.0, 1.0e-6, 1.0e6]) def test_negative_determinant_tolerance(self, scale): """ Test that the tolerance on a negative determinant is ``PSD_RTOL`` times the squared trace, for any overall scale of the matrix. """ covar = scale * np.array([[[0.25, -0.25], [-0.25, 0.25]], [[0.25, -0.25], [-0.25, 0.25]]]) trace = 0.5 * scale det = -PSD_RTOL * trace**2 * np.array([0.5, 2.0]) reg = regularize_covariance(covar, determinant=det) assert np.all(np.isfinite(reg[0])) assert np.all(np.isnan(reg[1])) def test_negative_trace(self): """ Test that a positive determinant with a negative trace is NaN. """ covar = np.array([[[-1.0, 0.0], [0.0, -1.0]]]) assert covariance_determinant(covar)[0] > 0 assert np.all(np.isnan(regularize(covar))) def test_input_not_modified(self, covariances): """ Test that the input array is left untouched. """ original = covariances.copy() reg = regularize(covariances) assert reg is not covariances assert_equal(covariances, original) def test_no_singular_sources(self): """ Test that resolved sources are returned unchanged. """ covar = np.array([[[4.0, 0.5], [0.5, 1.0]]]) assert_equal(regularize(covar), covar) def test_floor_is_minimum_eigenvalue(self): """ Test that every regularized eigenvalue reaches the single-pixel variance for random positive semidefinite matrices. """ rng = np.random.default_rng(1) arr = rng.normal(scale=0.3, size=(50, 2, 2)) covar = arr @ arr.swapaxes(1, 2) # symmetric eigvals = eigvals_from_covariance(regularize(covar)) assert np.all(eigvals >= PIXEL_VARIANCE * (1 - 1e-12)) def test_modified_matches_singular_mask(self): """ Test that the regularization modifies exactly the matrices selected by is_singular_covariance and is_invalid_covariance. The masks set the ``'singular_covariance'`` and ``'undefined_shape'`` flags but are not passed to regularize_covariance, so this pins that they use the same criteria. The singular matrices stay finite. The random symmetric matrices include ones that are not positive semidefinite, which are invalid and set to NaN. Most have a minor-axis variance close to ``PIXEL_VARIANCE``. """ rng = np.random.default_rng(4) n_matrices = 20000 theta = rng.uniform(0.0, np.pi, n_matrices) eig_max = 10.0**rng.uniform(-2.0, 3.0, n_matrices) eig_min = PIXEL_VARIANCE * rng.uniform(-0.5, 2.0, n_matrices) eig_min = np.minimum(eig_min, eig_max) cos, sin = np.cos(theta), np.sin(theta) covar = np.empty((n_matrices, 2, 2)) covar[:, 0, 0] = eig_max * cos**2 + eig_min * sin**2 covar[:, 1, 1] = eig_max * sin**2 + eig_min * cos**2 covar[:, 0, 1] = (eig_max - eig_min) * cos * sin covar[:, 1, 0] = covar[:, 0, 1] det = covariance_determinant(covar) singular = is_singular_covariance(covar, determinant=det) invalid = is_invalid_covariance(covar, determinant=det) reg = regularize_covariance(covar, determinant=det) modified = np.any(reg != covar, axis=(1, 2)) assert 0 < np.count_nonzero(singular) < n_matrices assert np.any(invalid) assert_equal(modified, singular | invalid) assert np.all(np.isfinite(reg[singular])) assert np.all(np.isnan(reg[invalid])) def test_idempotent(self, covariances): """ Test that regularizing a regularized matrix does not change it beyond rounding error. A floored eigenvalue equals ``PIXEL_VARIANCE`` only to within rounding, so it can be floored again by a tiny amount. """ reg = regularize(covariances) assert_allclose(regularize(reg), reg, rtol=1e-12) class TestCovarianceEigvals: """ Tests for eigvals_from_covariance. """ def test_values(self, covariances): """ Test the descending order and the NaN cases. Source 4 has eigenvalues 3 and -1. Source 5 is partially nonfinite. """ eigvals = eigvals_from_covariance(covariances) assert eigvals.shape == (6, 2) assert_allclose(eigvals[0], [4.0, 1.0]) assert_allclose(eigvals[3], [4.0, 0.05]) assert np.all(np.isnan(eigvals[4:])) def test_tilted(self): """ Test a matrix with a nonzero off-diagonal element. """ covar = np.array([[[1.0, 0.5], [0.5, 1.0]]]) assert_allclose(eigvals_from_covariance(covar), [[1.5, 0.5]]) def test_no_finite_matrices(self): """ Test an all-NaN input. """ eigvals = eigvals_from_covariance(np.full((2, 2, 2), np.nan)) assert eigvals.shape == (2, 2) assert np.all(np.isnan(eigvals)) def test_matches_eigvalsh(self): """ Test random positive definite matrices, including highly elongated ones, against np.linalg.eigvalsh. """ rng = np.random.default_rng(0) arr = rng.normal(size=(200, 2, 2)) arr[::2, :, 1] *= 1e-6 covar = arr @ arr.transpose(0, 2, 1) + 1e-13 * np.eye(2) expected = np.fliplr(np.linalg.eigvalsh(covar)) eigvals = eigvals_from_covariance(covar) assert_allclose(eigvals[:, 0], expected[:, 0], rtol=1e-13) # The closed form is limited only by the determinant rounding atol = 10.0 * np.finfo(float).eps * expected[:, 0] assert np.all(np.abs(eigvals[:, 1] - expected[:, 1]) <= atol) def test_infinite_element(self): """ Test that an infinite element gives NaN eigenvalues, without a warning. """ covar = np.array([[[np.inf, 0.0], [0.0, 1.0]], [[1.0, np.inf], [np.inf, 1.0]], [[np.inf, 0.0], [0.0, np.inf]]]) assert np.all(np.isnan(eigvals_from_covariance(covar))) def test_determinant_overflow(self): """ Test that both eigenvalues are accurate when the determinant overflows. """ covar = np.array([[[1e200, 0.0], [0.0, 1e190]], [[2e200, 1e200], [1e200, 2e200]]]) assert_allclose(eigvals_from_covariance(covar), [[1e200, 1e190], [3e200, 1e200]], rtol=1e-14) def test_zero_matrix(self): """ Test that an all-zero matrix has zero eigenvalues. """ eigvals = eigvals_from_covariance(np.zeros((1, 2, 2))) assert_equal(eigvals, [[0.0, 0.0]]) def test_major_axis_angle(): """ Test the angle convention, the isotropic case, and NaN propagation. """ var_a = np.array([4.0, 1.0, 1.0, 1.0, 1.0, np.inf]) var_b = np.array([1.0, 4.0, 1.0, 1.0, 1.0, np.inf]) covar_ab = np.array([0.0, 0.0, 0.5, -0.5, 0.0, 0.0]) angle = major_axis_angle(var_a, var_b, covar_ab) assert_allclose(angle[:5], [0.0, 90.0, 45.0, -45.0, 0.0]) assert np.isnan(angle[5]) def test_major_axis_angle_negative_zero(): """ Test that a negative-zero covariance gives 90 and not -90 degrees. """ var_a = np.array([1.0, 1.0]) var_b = np.array([2.0, 2.0]) covar_ab = np.array([-0.0, 0.0]) angle = major_axis_angle(var_a, var_b, covar_ab) assert_equal(angle, [90.0, 90.0]) def test_orientation_from_covariance(covariances): """ Test the orientation in degrees, including the (-90, 90] range. """ theta = orientation_from_covariance(covariances) assert theta.shape == (6,) assert_allclose(theta[0], 0.0) assert np.isnan(theta[5]) covar = np.array([[[1.0, 0.5], [0.5, 1.0]], [[1.0, -0.5], [-0.5, 1.0]], [[1.0, 0.0], [0.0, 4.0]]]) assert_allclose(orientation_from_covariance(covar), [45.0, -45.0, 90.0]) def test_orientation_from_covariance_infinite(): """ Test that two infinite variances give NaN without a warning. """ covar = np.array([[[np.inf, 0.0], [0.0, np.inf]]]) assert np.isnan(orientation_from_covariance(covar)[0]) class TestPixelToSkyCovariance: """ Tests for pixel_to_sky_covariance. """ def test_values(self, tan_wcs): """ Test the transport and the NaN cases. The x-axis flip changes the sign of the off-diagonal element. A nonfinite covariance or position gives a NaN matrix. """ pix_cov = np.array([[[4.0, 1.0], [1.0, 2.0]], [[np.nan, 1.0], [1.0, 2.0]], [[4.0, 1.0], [1.0, 2.0]]]) xycen = np.array([[49.0, 49.0], [49.0, 49.0], [np.nan, 49.0]]) sky_cov = pixel_to_sky_covariance(tan_wcs, pix_cov, xycen) assert sky_cov.shape == (3, 2, 2) assert_allclose(sky_cov[0], [[4.0, -1.0], [-1.0, 2.0]], rtol=1e-6) assert np.all(np.isnan(sky_cov[1:])) def test_no_finite_sources(self, tan_wcs): """ Test that the WCS is not evaluated when no source is finite. """ pix_cov = np.full((2, 2, 2), np.nan) xycen = np.full((2, 2), 49.0) sky_cov = pixel_to_sky_covariance(tan_wcs, pix_cov, xycen) assert sky_cov.shape == (2, 2, 2) assert np.all(np.isnan(sky_cov)) def test_sky_orientation_from_covariance(): """ Test the position angle, measured from North toward East. The matrices are ordered (East, North). """ sky_cov = np.array([[[4.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [0.0, 4.0]], [[1.0, 0.5], [0.5, 1.0]], [[1.0, -0.5], [-0.5, 1.0]], [[np.nan, 0.0], [0.0, 1.0]]]) angle = sky_orientation_from_covariance(sky_cov) assert angle.shape == (5,) assert_allclose(angle[:4], [90.0, 0.0, 45.0, -45.0]) assert np.isnan(angle[4]) def test_sky_orientation_from_covariance_infinite(): """ Test that two infinite variances give NaN without a warning. """ sky_cov = np.array([[[np.inf, 0.0], [0.0, np.inf]]]) assert np.isnan(sky_orientation_from_covariance(sky_cov)[0]) def test_empty_inputs(tan_wcs): """ Test that every helper accepts inputs with no sources. """ moments = np.empty((0, 3, 3)) covar = np.empty((0, 2, 2)) det = covariance_determinant(covar) assert det.shape == (0,) assert centroid_from_moments(moments).shape == (0, 2) assert inertia_tensor_from_moments(moments).shape == (0, 2, 2) assert covariance_from_moments(moments).shape == (0, 2, 2) assert covariance_min_eigval(covar, determinant=det).shape == (0,) mask = is_singular_covariance(covar, determinant=det) assert mask.shape == (0,) assert mask.dtype == bool assert regularize(covar).shape == (0, 2, 2) assert eigvals_from_covariance(covar).shape == (0, 2) assert orientation_from_covariance(covar).shape == (0,) sky_cov = pixel_to_sky_covariance(tan_wcs, covar, np.empty((0, 2))) assert sky_cov.shape == (0, 2, 2) assert sky_orientation_from_covariance(sky_cov).shape == (0,) astropy-photutils-24be529/photutils/utils/tests/test_optional_deps.py000066400000000000000000000147111526452531300264220ustar00rootroot00000000000000# 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_matplotlib_always_in_all(self): """ Test that ``HAS_MATPLOTLIB`` is always present in ``__all__``. """ assert 'HAS_MATPLOTLIB' 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_MATPLOTLIBB`` raises `AttributeError`. """ match = 'HAS_MATPLOTLIBB' with pytest.raises(AttributeError, match=match): _ = od_mod.HAS_MATPLOTLIBB 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., ``asdf-astropy`` -> ``ASDF_ASTROPY``). """ assert _dist_to_has_key('asdf-astropy') == 'ASDF_ASTROPY' 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_asdf_astropy_present(self): """ Test that ``asdf-astropy`` appears in the optional dependencies. """ result = _get_optional_deps(_pkg_dist_name, extra='all') assert 'asdf-astropy' in result def test_returns_dist_names_not_import_names(self): """ Test that the returned names are distribution names, not import names (e.g., ``asdf-astropy`` instead of ``asdf_astropy``). """ result = _get_optional_deps(_pkg_dist_name, extra='all') assert 'asdf-astropy' in result assert 'asdf_astropy' not in result astropy-photutils-24be529/photutils/utils/tests/test_overlap.py000066400000000000000000000035711526452531300252340ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _overlap module. """ import numpy as np import pytest from astropy.nddata import NoOverlapError, PartialOverlapError from astropy.nddata import overlap_slices as astropy_overlap_slices from photutils.utils._overlap import overlap_slices @pytest.mark.parametrize('mode', ['partial', 'trim', 'strict']) @pytest.mark.parametrize('small_shape', [(5, 7), [5, 7], np.array([5, 7])]) def test_overlap_slices(mode, small_shape): """ Test that the wrapper matches the astropy function for any array-like small-array shape. """ large_shape = (20, 30) position = (10, 12) result = overlap_slices(large_shape, small_shape, position, mode=mode) expected = astropy_overlap_slices(large_shape, (5, 7), position, mode=mode) assert result == expected def test_overlap_slices_default_mode(): """ Test that the default mode is 'partial'. """ result = overlap_slices((20, 30), np.array([5, 7]), (0, 0)) expected = astropy_overlap_slices((20, 30), (5, 7), (0, 0), mode='partial') assert result == expected match = 'Arrays overlap only partially' with pytest.raises(PartialOverlapError, match=match): overlap_slices((20, 30), np.array([5, 7]), (0, 0), mode='strict') @pytest.mark.parametrize('mode', ['partial', 'trim', 'strict']) @pytest.mark.parametrize('position', [(50, -16), (-16, 50), (-16, -16)]) def test_overlap_slices_edge_no_overlap(mode, position): """ Test that an array-valued small-array shape raises NoOverlapError when the small array ends exactly at the lower edge of the large array. """ match = 'Arrays do not overlap' with pytest.raises(NoOverlapError, match=match): overlap_slices((100, 100), np.array([31, 31]), position, mode=mode) astropy-photutils-24be529/photutils/utils/tests/test_parameters.py000066400000000000000000000167121526452531300257300ustar00rootroot00000000000000# 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 astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_equal from photutils.utils._parameters import (SigmaClipSentinelDefault, as_pair, create_default_sigmaclip, validate_table_columns) class TestAsPairBasic: """ Tests for as_pair scalar/tuple broadcasting and basic validation. """ def test_scalar_broadcast(self): assert_equal(as_pair('pair', 4), (4, 4)) def test_tuple_passthrough(self): assert_equal(as_pair('pair', (3, 4)), (3, 4)) def test_scalar_zero(self): assert_equal(as_pair('pair', 0), (0, 0)) def test_too_many_elements(self): match = 'must have 1 or 2 elements' with pytest.raises(ValueError, match=match): as_pair('pair', (1, 2, 3)) def test_2d_input(self): match = 'must be 1D' with pytest.raises(ValueError, match=match): as_pair('pair', np.array([[1, 2]])) def test_non_integer_dtype(self): match = 'must have integer values' with pytest.raises(ValueError, match=match): as_pair('pair', 1.5) def test_unsigned_integer_dtype(self): result = as_pair('p', np.array([3, 4], dtype=np.uint8)) assert_equal(result, (3, 4)) def test_bool_dtype(self): value = True match = 'must have integer values' with pytest.raises(ValueError, match=match): as_pair('p', value) @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('pair', value) class TestAsPairCheckOdd: """ Tests for the check_odd parameter. """ def test_odd_tuple(self): assert_equal(as_pair('pair', (3, 5), check_odd=True), (3, 5)) def test_odd_scalar(self): assert_equal(as_pair('pair', 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('pair', 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('pair', 0, lower_bound=(0, 0)) def test_inclusive_lower_bound(self): result = as_pair('pair', 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('pair', 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('pair', 1, lower_bound=(0,)) class TestAsPairUpperBound: """ Tests for upper_bound validation and clamping. """ def test_upper_bound_clipping(self): result = as_pair('pair', (10, 20), upper_bound=(5, 15)) assert_equal(result, (5, 15)) def test_upper_bound_no_clipping(self): result = as_pair('pair', (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('pair', (3, 4), upper_bound=(5,)) def test_upper_bound_check_odd(self): match = "'pair' was clamped to the upper bound and reduced to" with pytest.warns(AstropyUserWarning, match=match): result = as_pair('pair', 5, upper_bound=(4, 4), check_odd=True) assert_equal(result, (3, 3)) # Only one axis is clamped to an even value with pytest.warns(AstropyUserWarning, match=match): result = as_pair('pair', (5, 3), upper_bound=(4, 10), check_odd=True) assert_equal(result, (3, 3)) def test_upper_bound_check_odd_odd_bound(self): # Clamping to an odd bound also warns match = r"'pair' was clamped to the upper bound \(5, 5\)$" with pytest.warns(AstropyUserWarning, match=match): result = as_pair('pair', 7, upper_bound=(5, 5), check_odd=True) assert_equal(result, (5, 5)) # Only one axis is clamped match = r"'pair' was clamped to the upper bound \(5, 3\)$" with pytest.warns(AstropyUserWarning, match=match): result = as_pair('pair', (7, 3), upper_bound=(5, 9), check_odd=True) assert_equal(result, (5, 3)) def test_upper_bound_check_odd_no_warning(self): # A value within the upper bound does not warn result = as_pair('pair', (5, 3), upper_bound=(5, 4), check_odd=True) assert_equal(result, (5, 3)) 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 class TestValidateTableColumns: """ Tests for the ``validate_table_columns`` helper function. """ def test_str_column(self): result = validate_table_columns('a', ['a', 'b', 'c']) assert result == ['a'] def test_list_column(self): result = validate_table_columns(['a', 'c'], ['a', 'b', 'c']) assert result == ['a', 'c'] def test_tuple_column(self): result = validate_table_columns(('a', 'c'), ['a', 'b', 'c']) assert result == ['a', 'c'] def test_invalid_column(self): match = "Invalid column name\\(s\\): \\['invalid'\\]" with pytest.raises(ValueError, match=match): validate_table_columns(['a', 'invalid'], ['a', 'b', 'c']) def test_deprecated_column_allowed(self): result = validate_table_columns( 'old_name', ['new_name'], deprecated_names=['old_name']) assert result == ['old_name'] def test_deprecated_column_not_allowed_without_map(self): match = "Invalid column name\\(s\\): \\['old_name'\\]" with pytest.raises(ValueError, match=match): validate_table_columns('old_name', ['new_name']) def test_error_message_lists_allowed_columns(self): match = "The allowed column names are: \\['a', 'b', 'c'\\]" with pytest.raises(ValueError, match=match): validate_table_columns(['invalid'], ['c', 'a', 'b']) astropy-photutils-24be529/photutils/utils/tests/test_positional_kwargs.py000066400000000000000000000057731526452531300273310ustar00rootroot00000000000000# 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 photutils.utils._optional_deps import HAS_MATPLOTLIB from photutils.utils.colormaps import make_random_cmap from photutils.utils.cutouts import CutoutImage from photutils.utils.exceptions import PhotutilsDeprecationWarning 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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(PhotutilsDeprecationWarning, 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-24be529/photutils/utils/tests/test_progress_bars.py000066400000000000000000000056341526452531300264410ustar00rootroot00000000000000# 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-24be529/photutils/utils/tests/test_quantity_helpers.py000066400000000000000000000055411526452531300271630ustar00rootroot00000000000000# 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-24be529/photutils/utils/tests/test_repr.py000066400000000000000000000047561526452531300245420ustar00rootroot00000000000000# 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='...', 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-24be529/photutils/utils/tests/test_round.py000066400000000000000000000046301526452531300247100ustar00rootroot00000000000000# 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_0d_array(): """ Test round_half_away with 0D array inputs. """ ar = round_half_away(np.array(1.5)) assert np.isscalar(ar) assert isinstance(ar, int) assert ar == 2 ar = round_half_away(np.float64(-2.5)) assert isinstance(ar, int) assert ar == -3 ar = round_half_away(np.array(np.nan)) assert isinstance(ar, float) assert np.isnan(ar) 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-24be529/photutils/utils/tests/test_stats.py000066400000000000000000000060601526452531300247160ustar00rootroot00000000000000# 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. """ import photutils.utils._stats as stats_mod try: with patch('photutils.utils._optional_deps.HAS_BOTTLENECK', new=False): importlib.reload(stats_mod) arr = np.ones((5, 3)) result = stats_mod.nansum(arr) assert_equal(result, np.nansum(arr)) finally: # Reload again to restore the original state importlib.reload(stats_mod) astropy-photutils-24be529/photutils/utils/tests/test_thread_safety.py000066400000000000000000000105001526452531300263740ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests that the utils tools are safe for concurrent use. ``ImageDepth.__call__`` uses only local state (a fresh per-call random generator and a local copy of the SigmaClip instance), so concurrent calls on a shared, seeded instance must produce results identical to serial calls. The interpolator and coordinate tools are read-only or pure, so concurrent calls must also match serial results. """ import threading from concurrent.futures import ThreadPoolExecutor import numpy as np from astropy.stats import SigmaClip from numpy.testing import assert_equal from photutils.utils import ImageDepth, ShepardIDWInterpolator from photutils.utils._coords import make_random_xycoords N_THREADS = 8 N_CALLS = 3 def _run_concurrently(task, *, n_threads=N_THREADS, n_calls=N_CALLS): """ Run ``task`` in ``n_threads`` threads and return all results. A barrier maximizes the overlap of the concurrent calls. """ barrier = threading.Barrier(n_threads) def worker(): barrier.wait() return [task() for _ in range(n_calls)] with ThreadPoolExecutor(max_workers=n_threads) as executor: futures = [executor.submit(worker) for _ in range(n_threads)] return [result for future in futures for result in future.result()] def _make_depth_inputs(): """ Return a noise image and a source mask for the ImageDepth tests. """ rng = np.random.default_rng(1) data = rng.normal(0.0, 1.0, (150, 150)) mask = np.zeros(data.shape, dtype=bool) mask[60:80, 40:70] = True return data, mask def test_concurrent_image_depth_shared_instance(): """ Test that concurrent calls on a shared, seeded ImageDepth instance return results identical to a serial call and leave the result attributes consistent. """ data, mask = _make_depth_inputs() n_iters = 2 depth = ImageDepth(3, n_sigma=5.0, n_apertures=50, n_iters=n_iters, mask_pad=2, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) expected = depth(data, mask) expected_fluxes = depth.fluxes def task(): return depth(data, mask) for result in _run_concurrently(task): assert_equal(result, expected) # The result attributes reflect a single completed call assert len(depth.fluxes) == n_iters assert len(depth.apertures) == n_iters assert len(depth.flux_limits) == n_iters for fluxes, fluxes_exp in zip(depth.fluxes, expected_fluxes, strict=True): assert_equal(fluxes, fluxes_exp) def test_concurrent_image_depth_shared_sigma_clip(): """ Test that ImageDepth instances sharing a user-input SigmaClip instance can run concurrently, producing results identical to serial calls (each call must use a local copy of the SigmaClip instance). """ data, mask = _make_depth_inputs() sigma_clip = SigmaClip(sigma=3.0, maxiters=5) def make_depth(): return ImageDepth(3, n_sigma=5.0, n_apertures=50, n_iters=1, mask_pad=2, overlap=True, seed=42, sigma_clip=sigma_clip, progress_bar=False) expected = make_depth()(data, mask) def task(): return make_depth()(data, mask) for result in _run_concurrently(task): assert_equal(result, expected) def test_concurrent_idw_interpolator(): """ Test that concurrent queries of a shared ShepardIDWInterpolator return results identical to a serial call. """ rng = np.random.default_rng(0) coords = rng.random((500, 2)) values = np.sin(coords[:, 0] + coords[:, 1]) interp = ShepardIDWInterpolator(coords, values) positions = rng.random((100, 2)) expected = interp(positions) def task(): return interp(positions) for result in _run_concurrently(task, n_calls=10): assert_equal(result, expected) def test_concurrent_make_random_xycoords(): """ Test that concurrent seeded make_random_xycoords calls return results identical to a serial call. """ kwargs = {'min_separation': 2.0, 'seed': 7} expected = make_random_xycoords(100, (0, 500), (0, 300), **kwargs) def task(): return make_random_xycoords(100, (0, 500), (0, 300), **kwargs) for result in _run_concurrently(task, n_calls=10): assert_equal(result, expected) astropy-photutils-24be529/photutils/utils/tests/test_wcs.py000066400000000000000000000356271526452531300243670ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the wcs module. """ import warnings import astropy.units as u import numpy as np import pytest from astropy.utils.exceptions import AstropyUserWarning from astropy.wcs import WCS from astropy.wcs.utils import proj_plane_pixel_area from numpy.testing import assert_allclose from photutils.datasets import make_gwcs from photutils.utils._optional_deps import HAS_GWCS from photutils.utils.tests.wcs_test_helpers import (WCS_CDELT_ARCSEC, WCS_CENTER, make_sip_wcs) from photutils.utils.wcs import compute_pixel_area_map, compute_pixel_areas UNIFORM_AREA = WCS_CDELT_ARCSEC**2 def _make_sip_wcs(shape, coeff=2e-5): """ Build a TAN-SIP WCS whose pixel area varies smoothly across an array of the given shape. """ return make_sip_wcs(shape, coeffs={'A_2_0': coeff, 'B_0_2': coeff}) def _make_wide_tan_wcs(shape, deg_per_pix=0.01): """ Build a TAN WCS whose pixels are large enough that the pixel area varies non-polynomially across the field. A quadratic SIP distortion is reproduced exactly by the bicubic spline in ``compute_pixel_area_map``, so it cannot detect an interpolation error. The gnomonic area factor of a wide field can. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [shape[1] / 2 + 0.5, shape[0] / 2 + 0.5] wcs.wcs.crval = [WCS_CENTER.ra.deg, WCS_CENTER.dec.deg] wcs.wcs.cdelt = [-deg_per_pix, deg_per_pix] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs ALLSKY_SHAPE = (180, 360) def _make_allsky_wcs(projection): """ Build an all-sky WCS with 1 degree pixels in the given projection (e.g., ``'CAR'`` or ``'AIT'``) for an array of shape ``ALLSKY_SHAPE``. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [ALLSKY_SHAPE[1] / 2 + 0.5, ALLSKY_SHAPE[0] / 2 + 0.5] wcs.wcs.crval = [0.0, 0.0] wcs.wcs.cdelt = [-1.0, 1.0] wcs.wcs.ctype = [f'RA---{projection}', f'DEC--{projection}'] return wcs def _make_cd_wcs(cd_deg): """ Build a TAN WCS with the given CD matrix in degrees per pixel. """ wcs = WCS(naxis=2) wcs.wcs.crpix = [10.5, 10.5] wcs.wcs.crval = [WCS_CENTER.ra.deg, WCS_CENTER.dec.deg] wcs.wcs.cd = cd_deg wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] return wcs class TestComputePixelAreas: def test_uniform_wcs(self, simple_wcs): x = np.array([2.0, 10.0, 17.3]) y = np.array([1.5, 10.0, 4.0]) areas = compute_pixel_areas(simple_wcs, x, y) assert areas.shape == (3,) assert_allclose(areas, UNIFORM_AREA, rtol=1e-6) def test_scalar_input(self, simple_wcs): area = compute_pixel_areas(simple_wcs, 10.0, 10.0) assert isinstance(area, float) assert_allclose(area, UNIFORM_AREA, rtol=1e-6) @pytest.mark.parametrize('shape', [(3, 7), (4, 3), (2, 3, 4)]) def test_input_shape_preserved(self, shape): wcs = _make_sip_wcs((200, 200)) rng = np.random.default_rng(0) x = rng.uniform(0, 199, shape) y = rng.uniform(0, 199, shape) areas = compute_pixel_areas(wcs, x, y) assert areas.shape == shape expected = compute_pixel_areas(wcs, x.ravel(), y.ravel()) assert_allclose(areas.ravel(), expected, rtol=1e-12) def test_shape_mismatch(self, simple_wcs): match = 'x and y must have the same shape' with pytest.raises(ValueError, match=match): compute_pixel_areas(simple_wcs, [1.0, 2.0], [1.0, 2.0, 3.0]) def test_nonsquare_pixels(self, nonsquare_wcs): expected = (0.03 * 3600) * (0.05 * 3600) area = compute_pixel_areas(nonsquare_wcs, 10.0, 10.0) assert_allclose(area, expected, rtol=1e-6) def test_sheared_wcs(self): cd = np.array([[-2.0e-5, 0.5e-5], [0.3e-5, 3.0e-5]]) wcs = _make_cd_wcs(cd) expected = np.abs(np.linalg.det(cd)) * 3600**2 area = compute_pixel_areas(wcs, 10.0, 10.0) assert_allclose(area, expected, rtol=1e-6) def test_flipped_parity_is_positive(self, flipped_wcs): area = compute_pixel_areas(flipped_wcs, 10.0, 10.0) assert area > 0 assert_allclose(area, UNIFORM_AREA, rtol=1e-6) @pytest.mark.parametrize('bad', [np.nan, np.inf, -np.inf]) def test_nonfinite_position(self, simple_wcs, bad): x = np.array([10.0, bad, 10.0]) y = np.array([10.0, 10.0, bad]) with warnings.catch_warnings(): warnings.simplefilter('error') areas = compute_pixel_areas(simple_wcs, x, y) assert_allclose(areas[0], UNIFORM_AREA, rtol=1e-6) assert np.all(np.isnan(areas[1:])) def test_outside_projection(self): # An all-sky Aitoff map has corners outside the projection wcs = _make_allsky_wcs('AIT') areas = compute_pixel_areas(wcs, [0.0, 180.0], [0.0, 90.0]) assert np.isnan(areas[0]) assert np.isfinite(areas[1]) assert areas[1] > 0 def test_varies_with_distortion(self): wcs = _make_sip_wcs((200, 200)) areas = compute_pixel_areas(wcs, np.array([10.0, 190.0]), np.array([10.0, 190.0])) assert not np.isclose(areas[0], areas[1], rtol=1e-4) class TestPixelAreaMap: def test_uniform_wcs(self, simple_wcs): shape = (20, 30) area = compute_pixel_area_map(simple_wcs, shape) assert isinstance(area, np.ndarray) assert area.shape == shape assert_allclose(area, UNIFORM_AREA, rtol=1e-6) def test_matches_point_values_with_distortion(self): shape = (300, 200) wcs = _make_sip_wcs(shape) area = compute_pixel_area_map(wcs, shape) # The distortion must actually produce a gradient assert np.ptp(area) / area.mean() > 1e-3 rng = np.random.default_rng(0) y = rng.integers(0, shape[0], 50) x = rng.integers(0, shape[1], 50) expected = compute_pixel_areas(wcs, x, y) assert_allclose(area[y, x], expected, rtol=1e-6) @pytest.mark.parametrize('shape', [(1, 1), (2, 5), (10, 10), (65, 3), (3, 130)]) def test_small_shapes(self, shape): wcs = _make_sip_wcs(shape) area = compute_pixel_area_map(wcs, shape) assert area.shape == shape yy, xx = np.mgrid[:shape[0], :shape[1]] expected = compute_pixel_areas(wcs, xx.ravel(), yy.ravel()) assert_allclose(area.ravel(), expected, rtol=1e-6) def test_matches_point_values_wide_field(self): shape = (300, 200) wcs = _make_wide_tan_wcs(shape) area = compute_pixel_area_map(wcs, shape) # The gnomonic projection must actually produce a gradient assert np.ptp(area) / area.mean() > 1e-3 rng = np.random.default_rng(0) y = rng.integers(0, shape[0], 50) x = rng.integers(0, shape[1], 50) expected = compute_pixel_areas(wcs, x, y) assert_allclose(area[y, x], expected, rtol=1e-6) def test_step_independent(self): shape = (1040, 1040) wcs = _make_wide_tan_wcs(shape) fine = compute_pixel_area_map(wcs, shape, step=16) coarse = compute_pixel_area_map(wcs, shape, step=128) assert_allclose(fine, coarse, rtol=1e-6) @pytest.mark.parametrize('step', [7, 1000]) def test_step_capped(self, step): """ Test that a step larger than min(shape) // 8 is reduced to it with a warning, so the result is identical to that of the largest allowed step and differs from a smaller step. """ shape = (64, 48) wcs = _make_wide_tan_wcs(shape, deg_per_pix=0.2) capped = compute_pixel_area_map(wcs, shape, step=6) match = f'step={step} was reduced to 6 ' with pytest.warns(AstropyUserWarning, match=match): area = compute_pixel_area_map(wcs, shape, step=step) assert np.array_equal(area, capped) smaller = compute_pixel_area_map(wcs, shape, step=5) assert not np.array_equal(smaller, capped) def test_default_step_small_image(self): """ Test that the default step is silently reduced on a small image and equals the largest allowed step. """ shape = (64, 48) wcs = _make_wide_tan_wcs(shape, deg_per_pix=0.2) with warnings.catch_warnings(): warnings.simplefilter('error') area = compute_pixel_area_map(wcs, shape) expected = compute_pixel_area_map(wcs, shape, step=6) assert np.array_equal(area, expected) def test_default_step_large_image(self): shape = (600, 520) wcs = _make_wide_tan_wcs(shape) with warnings.catch_warnings(): warnings.simplefilter('error') area = compute_pixel_area_map(wcs, shape) expected = compute_pixel_area_map(wcs, shape, step=64) assert np.array_equal(area, expected) @pytest.mark.parametrize('projection', ['CAR', 'AIT']) def test_coarse_grid_outside_projection(self, projection): """ Test all-sky maps whose padded coarse grid leaves the valid region of the projection. Every pixel of the CAR map is valid, while the AIT map has corners outside the projection. In both cases the map must match the directly evaluated areas pixel for pixel, including the NaN pattern. """ wcs = _make_allsky_wcs(projection) yy, xx = np.mgrid[:ALLSKY_SHAPE[0], :ALLSKY_SHAPE[1]] expected = compute_pixel_areas(wcs, xx, yy) area = compute_pixel_area_map(wcs, ALLSKY_SHAPE) assert area.shape == ALLSKY_SHAPE assert_allclose(area, expected, rtol=1e-12, equal_nan=True) finite = np.isfinite(expected) if projection == 'CAR': assert np.all(finite) else: assert 0 < finite.mean() < 1 @pytest.mark.parametrize('shape', [(1, 1), (2, 5), (65, 3), (300, 200), (600, 520)]) def test_exact_matches_direct_evaluation(self, shape): """ Test that ``interpolate=False`` returns the areas evaluated directly at every pixel, without any interpolation. The largest shape has more pixels than one evaluation block, with a partial final block, so it also checks that the blocks are stitched together correctly. """ wcs = _make_wide_tan_wcs(shape) with warnings.catch_warnings(): warnings.simplefilter('error') area = compute_pixel_area_map(wcs, shape, interpolate=False) assert isinstance(area, np.ndarray) assert area.shape == shape yy, xx = np.mgrid[:shape[0], :shape[1]] expected = compute_pixel_areas(wcs, xx, yy) assert np.array_equal(area, expected) def test_exact_agrees_with_interpolated(self): shape = (300, 200) wcs = _make_wide_tan_wcs(shape) exact = compute_pixel_area_map(wcs, shape, interpolate=False) interpolated = compute_pixel_area_map(wcs, shape) assert np.ptp(exact) / exact.mean() > 1e-3 assert_allclose(interpolated, exact, rtol=1e-6) def test_exact_outside_projection(self): """ Test that ``interpolate=False`` keeps the NaN pattern of the pixels outside the projection. """ wcs = _make_allsky_wcs('AIT') area = compute_pixel_area_map(wcs, ALLSKY_SHAPE, interpolate=False) yy, xx = np.mgrid[:ALLSKY_SHAPE[0], :ALLSKY_SHAPE[1]] expected = compute_pixel_areas(wcs, xx, yy) assert np.array_equal(area, expected, equal_nan=True) assert 0 < np.isfinite(area).mean() < 1 @pytest.mark.parametrize(('shape', 'step'), [((300, 200), 1), ((12, 9), None), ((12, 9), 1)]) def test_step_one_is_exact(self, shape, step): """ Test that a step of 1, explicit or from the default on a tiny image, evaluates every pixel directly instead of interpolating. """ wcs = _make_wide_tan_wcs(shape) exact = compute_pixel_area_map(wcs, shape, interpolate=False) with warnings.catch_warnings(): warnings.simplefilter('error') area = compute_pixel_area_map(wcs, shape, step=step) assert np.array_equal(area, exact) def test_step_capped_to_one(self): """ Test that a step capped to 1 on a tiny image warns that every pixel is evaluated directly and gives the exact map. """ shape = (12, 9) wcs = _make_wide_tan_wcs(shape) exact = compute_pixel_area_map(wcs, shape, interpolate=False) match = ('step=64 was reduced to 1, so the area is evaluated ' 'directly at every pixel') with pytest.warns(AstropyUserWarning, match=match): area = compute_pixel_area_map(wcs, shape, step=64) assert np.array_equal(area, exact) @pytest.mark.parametrize('interpolate', ['no', None, 0, 1]) def test_invalid_interpolate(self, simple_wcs, interpolate): match = f'interpolate must be a boolean, got {interpolate!r}' with pytest.raises(TypeError, match=match): compute_pixel_area_map(simple_wcs, (20, 20), interpolate=interpolate) @pytest.mark.parametrize('step', [1, 16, 1000]) def test_exact_rejects_step(self, simple_wcs, step): match = 'step must be None when interpolate=False' with pytest.raises(ValueError, match=match): compute_pixel_area_map(simple_wcs, (20, 20), step=step, interpolate=False) @pytest.mark.parametrize('step', [0, -4, 2.5, True]) def test_invalid_step(self, simple_wcs, step): match = 'step must be a positive integer or None' with pytest.raises(ValueError, match=match): compute_pixel_area_map(simple_wcs, (20, 20), step=step) @pytest.mark.parametrize('shape', [(20,), (20, 20, 20), (0, 20), (20, -1), (20.0, 20), (True, 20)]) def test_invalid_shape(self, simple_wcs, shape): match = 'shape must be two positive integers' with pytest.raises(ValueError, match=match): compute_pixel_area_map(simple_wcs, shape) with pytest.raises(ValueError, match=match): compute_pixel_area_map(simple_wcs, shape, interpolate=False) @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_gwcs_bounding_box(self): shape = (40, 50) gwcs = make_gwcs(shape) gwcs.bounding_box = ((-0.5, shape[1] - 0.5), (-0.5, shape[0] - 0.5)) area = compute_pixel_area_map(gwcs, shape) assert np.all(np.isfinite(area)) # make_gwcs has a uniform 0.1 arcsec pixel scale assert_allclose(area, 0.01, rtol=1e-6) def test_matches_astropy_proj_plane_area(self, simple_wcs): expected = (proj_plane_pixel_area(simple_wcs) * u.deg**2).to_value( u.arcsec**2) area = compute_pixel_area_map(simple_wcs, (20, 20)) assert_allclose(area, expected, rtol=1e-6) astropy-photutils-24be529/photutils/utils/tests/test_wcs_helpers.py000066400000000000000000001577771526452531300261240ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _wcs_helpers module. """ import warnings import astropy.units as u import numpy as np import pytest from astropy.coordinates import ICRS, Angle, Galactic, SkyCoord from astropy.wcs import WCS as APWCS from astropy.wcs.wcsapi import HighLevelWCSWrapper from numpy.testing import assert_allclose from photutils.datasets import make_gwcs from photutils.utils._optional_deps import HAS_GWCS from photutils.utils._wcs_helpers import (_frame_rotation, compute_pixel_to_sky_jacobians, pixel_shape_to_sky_svd, pixel_to_sky_mean_scale, pixel_to_sky_svd_scales, sky_shape_to_pixel_svd, sky_to_pixel_mean_scale, sky_to_pixel_svd_scales) from photutils.utils.tests.wcs_test_helpers import (WCS_CDELT_ARCSEC, WCS_CENTER, CountingWCS, make_sip_wcs) # WCS centers that historically broke the flat-sky finite-difference # Jacobian and SVD shape conversions: # # * near the celestial poles, where the small ``cos(dec)`` factor makes # the ``xi = cos(dec) * dRA`` small-angle approximation fail and the # (xi, eta) sampling becomes wildly nonlinear, and # # * across the RA = 0 / 360 wraparound, where naive longitude # subtraction crosses the 0 / 360 cut. TROUBLESOME_CENTERS = [ pytest.param(0.0, 80.0, id='pole_dec=80'), pytest.param(0.0, 89.0, id='pole_dec=89'), pytest.param(0.0, 89.99, id='pole_dec=89.99'), pytest.param(0.0, -80.0, id='pole_dec=-80'), pytest.param(0.0, -89.99, id='pole_dec=-89.99'), pytest.param(0.0, 30.0, id='ra_wrap=0'), pytest.param(0.001, 30.0, id='ra_wrap=0.001'), pytest.param(359.999, 30.0, id='ra_wrap=359.999'), ] def _make_sip_wcs(ra_deg, dec_deg): """ Build a small TAN-SIP WCS centered at (ra_deg, dec_deg). The SIP terms are tiny but nonzero, so the distortion code paths are exercised. """ center = SkyCoord(ra_deg * u.deg, dec_deg * u.deg) return make_sip_wcs(center=center, coeffs={'A_2_0': 1e-6, 'B_0_2': 1e-6}) def _make_rotated_wcs(rotation_deg, scale_arcsec=0.25, crval=(150.0, 30.0)): wcs = APWCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = [50.0, 50.0] wcs.wcs.crval = list(crval) theta = np.deg2rad(rotation_deg) scale = scale_arcsec / 3600.0 wcs.wcs.cd = scale * np.array( [[-np.cos(theta), np.sin(theta)], [np.sin(theta), np.cos(theta)]]) return wcs def test_compute_pixel_to_sky_jacobians(): """ Test the singular values of the vectorized forward Jacobians against the reciprocals of the per-region sky-to-pixel scales at several positions. This is a consistency check between the vectorized and per-region paths. The independent great-circle reference is in ``TestJacobianEvaluation``. """ wcs = _make_rotated_wcs(30.0) x = np.array([10.0, 50.0, 80.3]) y = np.array([20.0, 50.0, 61.7]) jacs = compute_pixel_to_sky_jacobians(wcs, x, y) assert jacs.shape == (3, 2, 2) for i in range(x.size): _, major, minor, _ = sky_to_pixel_svd_scales(wcs, None, pixcoord=(x[i], y[i])) sv = np.linalg.svd(jacs[i], compute_uv=False) assert_allclose(sv, [1.0 / minor, 1.0 / major], rtol=1e-10) def test_compute_pixel_to_sky_jacobians_scale(): """ Test that an axis-aligned WCS gives |F| entries equal to the pixel scale in arcsec. """ wcs = _make_rotated_wcs(0.0, scale_arcsec=0.5, crval=(150.0, 0.0)) jacs = compute_pixel_to_sky_jacobians(wcs, np.array([50.0]), np.array([50.0])) assert_allclose(np.abs(jacs[0]), [[0.5, 0.0], [0.0, 0.5]], atol=1e-4) class TestMeanScale: """ Tests for `sky_to_pixel_mean_scale` and `pixel_to_sky_mean_scale`. """ def test_sky_to_pixel_return_types(self, simple_wcs): """ Should return (tuple, float). """ pix_position, scale = sky_to_pixel_mean_scale(simple_wcs, WCS_CENTER) assert isinstance(pix_position, tuple) assert isinstance(scale, float) def test_pixel_to_sky_return_type(self, simple_wcs, center_xy_coord): """ Should return a float. """ scale = pixel_to_sky_mean_scale(simple_wcs, center_xy_coord) assert isinstance(scale, float) def test_sky_to_pixel_simple_scale(self, simple_wcs): """ For an isotropic WCS, the mean scale should equal 1/WCS_CDELT_ARCSEC. """ _, scale = sky_to_pixel_mean_scale(simple_wcs, WCS_CENTER) 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 = pixel_to_sky_mean_scale(simple_wcs, center_xy_coord) assert_allclose(scale, WCS_CDELT_ARCSEC) def test_sky_to_pixel_sip_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(sip_wcs, WCS_CENTER) assert_allclose(scale, 1.0 / WCS_CDELT_ARCSEC, rtol=1e-6) def test_pixel_to_sky_sip_scale(self, sip_wcs): """ For a SIP WCS near the reference pixel, the mean scale should be close to the undistorted value. """ scale = pixel_to_sky_mean_scale(sip_wcs, (9.5, 9.5)) assert_allclose(scale, WCS_CDELT_ARCSEC, rtol=1e-6) @pytest.mark.parametrize('wcs_name', ['simple_wcs', 'rotated_wcs', 'nonsquare_wcs', 'flipped_wcs', 'swapped_wcs', 'sip_wcs']) def test_roundtrip_scale(self, wcs_name, request): """ Sky -> pixel mean_scale * pixel -> sky mean_scale must be exactly 1, even where the Jacobian is anisotropic. """ wcs = request.getfixturevalue(wcs_name) center_pix, s2p = sky_to_pixel_mean_scale(wcs, WCS_CENTER) p2s = pixel_to_sky_mean_scale(wcs, center_pix) assert_allclose(s2p * p2s, 1.0, rtol=1e-12) def test_roundtrip_scale_off_axis(self): """ Far from the tangent point of a wide-field TAN projection the Jacobian is anisotropic even for square pixels, and the scale must still invert exactly. """ wcs = APWCS(naxis=2) wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.crpix = [50.5, 50.5] wcs.wcs.crval = [WCS_CENTER.ra.deg, WCS_CENTER.dec.deg] wcs.wcs.cdelt = [-0.02, 0.02] pixcoord = (-50.5, 299.5) jac = compute_pixel_to_sky_jacobians(wcs, *pixcoord)[0] s_max, s_min = np.linalg.svd(jac, compute_uv=False) assert (s_max - s_min) / (s_max + s_min) > 1e-3 p2s = pixel_to_sky_mean_scale(wcs, pixcoord) skycoord = wcs.pixel_to_world(*pixcoord) _, s2p = sky_to_pixel_mean_scale(wcs, skycoord) assert_allclose(s2p * p2s, 1.0, rtol=1e-12) def test_center_coordinates(self, simple_wcs): """ The returned pix_position should match world_to_pixel. """ pix_position, _ = sky_to_pixel_mean_scale(simple_wcs, WCS_CENTER) 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) def test_nonsquare_mean_scale(self, nonsquare_wcs): """ For nonsquare pixels the mean scale should be the geometric mean of the two singular values (1/cdelt_x and 1/cdelt_y in pix/arcsec). """ _, scale = sky_to_pixel_mean_scale(nonsquare_wcs, WCS_CENTER) cdelt_x = 0.03 * 3600 cdelt_y = 0.05 * 3600 expected = 1.0 / np.sqrt(cdelt_x * cdelt_y) assert_allclose(scale, expected, rtol=1e-6) def test_nonsquare_same_for_sip(self, nonsquare_wcs): """ The scale formula must not depend on whether the WCS carries distortion terms. A SIP WCS with the same nonsquare pixel scales and negligible coefficients gives the same scale. """ header = nonsquare_wcs.to_header() header['CTYPE1'] = 'RA---TAN-SIP' header['CTYPE2'] = 'DEC--TAN-SIP' header['A_ORDER'] = header['B_ORDER'] = 2 header['A_2_0'] = header['B_0_2'] = 1e-12 sip_wcs = APWCS(header) assert sip_wcs.has_distortion _, scale = sky_to_pixel_mean_scale(nonsquare_wcs, WCS_CENTER) _, scale_sip = sky_to_pixel_mean_scale(sip_wcs, WCS_CENTER) assert_allclose(scale_sip, scale, rtol=1e-8) class TestSVDShapeConversions: """ Tests for `pixel_shape_to_sky_svd` and `sky_shape_to_pixel_svd`. """ def test_pixel_to_sky_return_types(self, simple_wcs, center_xy_coord): """ Should return (float, float, Angle). """ w, h, angle = pixel_shape_to_sky_svd( simple_wcs, center_xy_coord, 10.0, 5.0, 0.5) 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_shape_to_pixel_svd( simple_wcs, WCS_CENTER, 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) @pytest.mark.parametrize('wcs_name', ['sip_wcs', 'nonsquare_wcs', 'flipped_wcs']) def test_pixel_to_sky_array_shapes(self, wcs_name, request): """ Array widths and heights give the per-shape scalar results in one low-level WCS evaluation. The middle shape is circular, so the circular-input angle path is exercised as well. """ real_wcs = request.getfixturevalue(wcs_name) widths = np.array([10.0, 6.0, 4.0]) heights = np.array([5.0, 6.0, 7.0]) wcs = CountingWCS(real_wcs) w, h, angle = pixel_shape_to_sky_svd(wcs, (12.0, 7.0), widths, heights, 0.3) assert wcs.n_pixel_to_world_values == 1 assert wcs.n_pixel_to_world == 0 assert w.shape == h.shape == angle.shape == (3,) for i in range(3): w1, h1, a1 = pixel_shape_to_sky_svd( real_wcs, (12.0, 7.0), widths[i], heights[i], 0.3) assert_allclose(w[i], w1, rtol=1e-12) assert_allclose(h[i], h1, rtol=1e-12) assert_allclose(angle[i].deg, a1.deg, atol=1e-10) @pytest.mark.parametrize('wcs_name', ['sip_wcs', 'nonsquare_wcs', 'flipped_wcs']) def test_sky_to_pixel_array_shapes(self, wcs_name, request): """ Array widths and heights give the per-shape scalar results with one low-level WCS evaluation and, given the pixel position, no inversion. """ real_wcs = request.getfixturevalue(wcs_name) widths = np.array([1.0, 0.6, 0.4]) heights = np.array([0.5, 0.6, 0.7]) pixcoord = tuple(float(v) for v in real_wcs.world_to_pixel(WCS_CENTER)) wcs = CountingWCS(real_wcs) center, w, h, angle = sky_shape_to_pixel_svd( wcs, WCS_CENTER, widths, heights, 0.3, pixcoord=pixcoord) assert wcs.n_pixel_to_world_values == 1 assert wcs.n_pixel_to_world == 0 assert wcs.n_world_to_pixel == 0 assert w.shape == h.shape == angle.shape == (3,) for i in range(3): c1, w1, h1, a1 = sky_shape_to_pixel_svd( real_wcs, WCS_CENTER, widths[i], heights[i], 0.3) assert_allclose(w[i], w1, rtol=1e-12) assert_allclose(h[i], h1, rtol=1e-12) assert_allclose(angle[i].deg, a1.deg, atol=1e-10) assert_allclose(center, c1, atol=1e-10) def test_shape_broadcasting(self, sip_wcs): """ A scalar width broadcasts against an array of heights, and scalar inputs give scalar outputs. """ w, h, angle = pixel_shape_to_sky_svd(sip_wcs, (12.0, 7.0), 8.0, [4.0, 8.0], 0.3) assert w.shape == h.shape == angle.shape == (2,) w1, h1, a1 = pixel_shape_to_sky_svd(sip_wcs, (12.0, 7.0), 8.0, 8.0, 0.3) assert isinstance(w1, float) assert isinstance(h1, float) assert a1.isscalar assert_allclose(w[1], w1, rtol=1e-12) assert_allclose(angle[1].deg, a1.deg, atol=1e-10) 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_shape_to_pixel_svd( simple_wcs, WCS_CENTER, sky_w, sky_h, sky_a) rw, rh, ra = pixel_shape_to_sky_svd( simple_wcs, center_pix, 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_shape_to_sky_svd( simple_wcs, center_xy_coord, pix_w, pix_h, pix_a) _, rw, rh, ra = sky_shape_to_pixel_svd( simple_wcs, WCS_CENTER, 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_shape_to_sky_svd( simple_wcs, center_xy_coord, 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_zero_width_keeps_axis_assignment(self, simple_wcs, center_xy_coord): """ Test that a zero-width ellipse maps its width axis to a zero sky width rather than swapping the axes. """ sky_width, sky_height, _ = pixel_shape_to_sky_svd( simple_wcs, center_xy_coord, 0.0, 2.0, 0.3) assert sky_width == 0.0 assert_allclose(sky_height, 2.0 * WCS_CDELT_ARCSEC, rtol=1e-6) 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_shape_to_sky_svd( simple_wcs, center_xy_coord, 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_shape_to_sky_svd( sip_wcs, xy_coord, 8.0, 4.0, 0.0) assert sw > 0 assert sh > 0 def test_sip_wcs_roundtrip(self, sip_wcs): """ Round-trip 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_shape_to_pixel_svd( sip_wcs, WCS_CENTER, sky_w, sky_h, sky_a) rw, rh, ra = pixel_shape_to_sky_svd( sip_wcs, center_pix, 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_shape_to_sky_svd( simple_wcs, center_xy_coord, 10.0, 5.0, 0.5) assert 0.0 <= angle.deg < 360.0 @pytest.mark.parametrize('angle_deg', [0.0, 40.0, 130.0]) def test_circular_input_preserves_angle(self, rotated_wcs, angle_deg): """ For a circular input shape (width == height), the SVD principal axis is arbitrary, so the helper falls back to the mapped width semiaxis to preserve the input rotation angle. The angle must round-trip through pixel -> sky -> pixel. """ pixcoord = (9.5, 9.5) angle_rad = np.deg2rad(angle_deg) sw, sh, sky_angle = pixel_shape_to_sky_svd( rotated_wcs, pixcoord, 6.0, 6.0, angle_rad) assert_allclose(sw, sh, rtol=1e-8) sky_center = rotated_wcs.pixel_to_world(*pixcoord) _, pw, ph, pix_angle = sky_shape_to_pixel_svd( rotated_wcs, sky_center, sw, sh, sky_angle.rad) assert_allclose(pw, ph, rtol=1e-8) diff = (pix_angle.deg - angle_deg + 180) % 360 - 180 assert_allclose(diff, 0.0, atol=1e-6) @pytest.mark.parametrize(('center_ra', 'center_dec'), TROUBLESOME_CENTERS) def test_roundtrip_at_pole_and_wrap(self, center_ra, center_dec): """ Test that a directed sky shape converted to pixels and back round-trips to itself near the celestial poles and across the RA = 0 / 360 wraparound. """ wcs = _make_sip_wcs(center_ra, center_dec) skycoord = SkyCoord(center_ra * u.deg, center_dec * u.deg) center, pw, ph, pa = sky_shape_to_pixel_svd( wcs, skycoord, 2.0, 1.0, np.deg2rad(30)) assert pw > 1.0 assert ph > 1.0 sw, sh, _ = pixel_shape_to_sky_svd( wcs, center, pw, ph, pa.to_value(u.radian)) assert_allclose(sw, 2.0, rtol=1e-3) assert_allclose(sh, 1.0, rtol=1e-3) def _project_sky_ellipse_boundary(skycoord, wcs, a_arcsec, b_arcsec, pa_rad, *, n_points=720): """ Project the boundary of a sky ellipse to pixel coordinates. The boundary points are computed in the tangent plane (``xi`` = East, ``eta`` = North) and offset from ``skycoord`` using great-circle geometry, then converted to pixel coordinates with ``wcs.world_to_pixel``. This is an independent ground truth for the pixel image of a sky ellipse. """ theta = np.linspace(0, 2 * np.pi, n_points, endpoint=False) xi = (a_arcsec * np.cos(theta) * np.sin(pa_rad) + b_arcsec * np.sin(theta) * np.cos(pa_rad)) eta = (a_arcsec * np.cos(theta) * np.cos(pa_rad) - b_arcsec * np.sin(theta) * np.sin(pa_rad)) sep = np.hypot(xi, eta) * u.arcsec posang = np.arctan2(xi, eta) * u.rad pts = skycoord.directional_offset_by(posang, sep) x, y = wcs.world_to_pixel(pts) return np.asarray(x, dtype=float), np.asarray(y, dtype=float) def _ellipse_implicit_residual(x, y, center, width, height, angle_rad): """ Evaluate the implicit ellipse equation for points ``(x, y)``. Returns ``(u / a)**2 + (v / b)**2`` where ``(u, v)`` are the point offsets from ``center`` rotated into the ellipse frame and ``a``, ``b`` are the semiaxes. Points exactly on the ellipse boundary yield 1. """ cx, cy = center dx = x - cx dy = y - cy cos_a = np.cos(angle_rad) sin_a = np.sin(angle_rad) u_axis = dx * cos_a + dy * sin_a v_axis = -dx * sin_a + dy * cos_a return (u_axis / (width / 2)) ** 2 + (v_axis / (height / 2)) ** 2 class TestFlippedParityWCS: """ Regression tests for sky <-> pixel shape conversions with a flipped-parity WCS (North down, East left, positive determinant). Such a WCS previously produced apertures that were mirrored about the x-axis relative to the correct orientation. """ PA_DEGS = (0.0, 30.0, 75.0, 130.0, 228.0, 310.0) def test_flipped_wcs_has_positive_parity(self, flipped_wcs): """ The flipped WCS fixture must have a positive-determinant pixel scale matrix (parity = +1), opposite the standard convention. """ assert np.linalg.det(flipped_wcs.pixel_scale_matrix) > 0 @pytest.mark.parametrize('pa_deg', PA_DEGS) def test_sky_to_pixel_matches_projection(self, flipped_wcs, pa_deg): """ The pixel ellipse from ``sky_shape_to_pixel_svd`` must match the true projection of the sky ellipse boundary (not its mirror image) for a flipped-parity WCS. """ a_arcsec, b_arcsec = 3.0, 1.5 pa_rad = np.deg2rad(pa_deg) center, pw, ph, pangle = sky_shape_to_pixel_svd( flipped_wcs, WCS_CENTER, 2 * a_arcsec, 2 * b_arcsec, pa_rad) x, y = _project_sky_ellipse_boundary( WCS_CENTER, flipped_wcs, a_arcsec, b_arcsec, pa_rad) resid = _ellipse_implicit_residual( x, y, center, pw, ph, pangle.rad) # All projected boundary points must lie on the pixel ellipse. assert_allclose(resid, 1.0, atol=1e-3) @pytest.mark.parametrize('pa_deg', PA_DEGS) def test_normal_wcs_matches_projection(self, simple_wcs, pa_deg): """ The same check for a standard-parity WCS (a control case that already worked before the fix). """ a_arcsec, b_arcsec = 3.0, 1.5 pa_rad = np.deg2rad(pa_deg) center, pw, ph, pangle = sky_shape_to_pixel_svd( simple_wcs, WCS_CENTER, 2 * a_arcsec, 2 * b_arcsec, pa_rad) x, y = _project_sky_ellipse_boundary( WCS_CENTER, simple_wcs, a_arcsec, b_arcsec, pa_rad) resid = _ellipse_implicit_residual( x, y, center, pw, ph, pangle.rad) assert_allclose(resid, 1.0, atol=1e-3) def test_roundtrip_sky_pixel_sky(self, flipped_wcs): """ Sky -> pixel -> sky should recover the original ellipse for a flipped-parity WCS. """ sky_w, sky_h, sky_a = 6.0, 3.0, np.deg2rad(228.0) center_pix, pw, ph, pa = sky_shape_to_pixel_svd( flipped_wcs, WCS_CENTER, sky_w, sky_h, sky_a) rw, rh, ra = pixel_shape_to_sky_svd( flipped_wcs, center_pix, 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 _make_sheared_wcs(): """ Build an anisotropic, sheared (nonorthogonal CD matrix) TAN WCS. The unequal, nonorthogonal CD terms make the local Jacobian both anisotropic and rotated, so the SVD principal axes have a nontrivial orientation. This exercises the angle convention of the scale helpers. """ wcs = APWCS(naxis=2) wcs.wcs.crpix = [50, 50] wcs.wcs.crval = [WCS_CENTER.ra.deg, WCS_CENTER.dec.deg] wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] wcs.wcs.cd = np.array([[-0.0008, 0.0003], [0.0002, 0.0005]]) return wcs def _project_pixel_circle_to_sky_tangent(pixcoord, wcs, radius_pix, *, n_points=720): """ Project a pixel circle boundary to tangent-plane sky offsets. The boundary points of a pixel circle are converted to sky coordinates and expressed as tangent-plane offsets (``xi`` = East, ``eta`` = North) in arcsec relative to the circle center, using great-circle separation and position angle. This is an independent ground truth for the sky image of a pixel circle. """ theta = np.linspace(0, 2 * np.pi, n_points, endpoint=False) cx, cy = pixcoord x = cx + radius_pix * np.cos(theta) y = cy + radius_pix * np.sin(theta) center = wcs.pixel_to_world(cx, cy) pts = wcs.pixel_to_world(x, y) arcsec_per_rad = 3600.0 * np.degrees(1) sep = center.separation(pts).rad posang = center.position_angle(pts).rad xi = sep * np.sin(posang) * arcsec_per_rad eta = sep * np.cos(posang) * arcsec_per_rad return np.asarray(xi, dtype=float), np.asarray(eta, dtype=float) def _sky_ellipse_implicit_residual(xi, eta, width, height, pa_rad): """ Evaluate the implicit sky-ellipse equation in the tangent plane. The major axis (of full length ``width``) is at position angle ``pa_rad`` measured from North (``eta``) toward East (``xi``). Points exactly on the ellipse boundary yield 1. """ cos_pa = np.cos(pa_rad) sin_pa = np.sin(pa_rad) p_major = xi * sin_pa + eta * cos_pa p_minor = xi * cos_pa - eta * sin_pa return (p_major / (width / 2)) ** 2 + (p_minor / (height / 2)) ** 2 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( simple_wcs, WCS_CENTER) 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 (float, float, Angle). """ smaj, smin, angle = pixel_to_sky_svd_scales( simple_wcs, center_xy_coord) assert isinstance(smaj, (float, np.floating)) assert isinstance(smin, (float, np.floating)) assert isinstance(angle, Angle) def test_sky_to_pixel_simple_isotropic(self, simple_wcs): """ For a nondistorted, square-pixel WCS the two scale factors are equal and match 1 / WCS_CDELT_ARCSEC (pixels per arcsec). """ _, smaj, smin, angle = sky_to_pixel_svd_scales( simple_wcs, WCS_CENTER) expected = 1.0 / WCS_CDELT_ARCSEC assert_allclose(smaj, expected, rtol=1e-6) assert_allclose(smin, expected, rtol=1e-6) assert 0.0 <= angle.deg < 360.0 def test_pixel_to_sky_simple_isotropic(self, simple_wcs, center_xy_coord): """ For a nondistorted, square-pixel WCS the two scale factors are equal and match WCS_CDELT_ARCSEC (arcsec per pixel). """ smaj, smin, angle = pixel_to_sky_svd_scales( simple_wcs, center_xy_coord) assert_allclose(smaj, WCS_CDELT_ARCSEC, rtol=1e-6) assert_allclose(smin, WCS_CDELT_ARCSEC, rtol=1e-6) assert 0.0 <= angle.deg < 360.0 def test_sky_to_pixel_nonsquare_anisotropic(self, nonsquare_wcs): """ For nonsquare pixels the major scale exceeds the minor scale, and both match the inverse of the corresponding pixel scales. """ _, smaj, smin, _ = sky_to_pixel_svd_scales(nonsquare_wcs, WCS_CENTER) assert smaj > smin # cdelt = [-0.03, 0.05] deg, so pixels/arcsec = 1 / (cdelt_arcsec) assert_allclose(smaj, 1.0 / (0.03 * 3600), rtol=1e-5) assert_allclose(smin, 1.0 / (0.05 * 3600), rtol=1e-5) def test_pixel_to_sky_nonsquare_anisotropic(self, nonsquare_wcs): """ For nonsquare pixels the major scale exceeds the minor scale, and both match the corresponding pixel scales (arcsec/pixel). """ smaj, smin, _ = pixel_to_sky_svd_scales(nonsquare_wcs, (9.5, 9.5)) assert smaj > smin assert_allclose(smaj, 0.05 * 3600, rtol=1e-5) assert_allclose(smin, 0.03 * 3600, rtol=1e-5) def test_scales_descending(self, sip_wcs): """ The returned scales are singular values in descending order, so the major scale is always >= the minor scale. """ _, smaj_s, smin_s, _ = sky_to_pixel_svd_scales(sip_wcs, WCS_CENTER) smaj_p, smin_p, _ = pixel_to_sky_svd_scales(sip_wcs, (9.5, 9.5)) assert smaj_s >= smin_s assert smaj_p >= smin_p @pytest.mark.parametrize('wcs_name', ['rotated_wcs', 'flipped_wcs']) def test_sky_to_pixel_matches_projection(self, wcs_name, request): """ A circular sky region converted with the SVD scales must match the true projection of the sky circle boundary, including the correct major-axis orientation and parity. """ wcs = request.getfixturevalue(wcs_name) radius = 5.0 # arcsec center, smaj, smin, pixel_angle = sky_to_pixel_svd_scales( wcs, WCS_CENTER) x, y = _project_sky_ellipse_boundary( WCS_CENTER, wcs, radius, radius, 0.0) resid = _ellipse_implicit_residual( x, y, center, 2 * radius * smaj, 2 * radius * smin, pixel_angle.rad) assert_allclose(resid, 1.0, atol=1e-3) def test_sky_to_pixel_matches_projection_sheared(self): """ For an anisotropic, sheared WCS the projected sky circle is an ellipse whose principal axes and orientation must match the SVD scales and pixel angle. """ wcs = _make_sheared_wcs() radius = 20.0 # arcsec center, smaj, smin, pixel_angle = sky_to_pixel_svd_scales( wcs, WCS_CENTER) assert smaj > smin x, y = _project_sky_ellipse_boundary( WCS_CENTER, wcs, radius, radius, 0.0) resid = _ellipse_implicit_residual( x, y, center, 2 * radius * smaj, 2 * radius * smin, pixel_angle.rad) assert_allclose(resid, 1.0, atol=2e-3) def test_pixel_to_sky_matches_projection_sheared(self): """ For an anisotropic, sheared WCS the sky image of a pixel circle is an ellipse whose principal axes and position angle must match the SVD scales and sky angle. """ wcs = _make_sheared_wcs() pixcoord = (50.0, 50.0) radius_pix = 10.0 smaj, smin, sky_angle = pixel_to_sky_svd_scales(wcs, pixcoord) assert smaj > smin xi, eta = _project_pixel_circle_to_sky_tangent( pixcoord, wcs, radius_pix) resid = _sky_ellipse_implicit_residual( xi, eta, 2 * radius_pix * smaj, 2 * radius_pix * smin, sky_angle.rad) assert_allclose(resid, 1.0, atol=2e-3) def test_inverse_scale_consistency(self): """ The singular values of the Jacobian and its inverse are reciprocals, so the pixel-to-sky major (minor) scale equals the reciprocal of the sky-to-pixel minor (major) scale. """ wcs = _make_sheared_wcs() center_pix, smaj_p, smin_p, _ = sky_to_pixel_svd_scales( wcs, WCS_CENTER) smaj_s, smin_s, _ = pixel_to_sky_svd_scales(wcs, center_pix) assert_allclose(smaj_s, 1.0 / smin_p, rtol=1e-6) assert_allclose(smin_s, 1.0 / smaj_p, rtol=1e-6) def test_angles_wrapped(self): """ Both helpers return angles wrapped to [0, 360) degrees. """ wcs = _make_sheared_wcs() _, _, _, pixel_angle = sky_to_pixel_svd_scales(wcs, WCS_CENTER) _, _, sky_angle = pixel_to_sky_svd_scales(wcs, (50.0, 50.0)) assert 0.0 <= pixel_angle.deg < 360.0 assert 0.0 <= sky_angle.deg < 360.0 def _make_quadratic_sip_wcs(coeff=1e-3): """ Build a TAN-SIP WCS whose distortion is a pure quadratic in the pixel offset from CRPIX. At CRPIX the quadratic term has zero slope, so the true local scale is exactly CDELT. A one-sided finite difference is biased there by a fraction ``coeff``, while a central difference is exact for a quadratic. """ center = SkyCoord(150.0 * u.deg, 0.0 * u.deg) coeffs = {'A_2_0': coeff, 'B_0_2': coeff} return make_sip_wcs((100, 100), center=center, coeffs=coeffs) class TestCentralDifferences: """ Tests that the finite-difference Jacobians are unbiased where the distortion has curvature. """ def test_pixel_to_sky_jacobians_unbiased_at_crpix(self): wcs = _make_quadratic_sip_wcs() x = np.array([wcs.wcs.crpix[0] - 1.0]) y = np.array([wcs.wcs.crpix[1] - 1.0]) jac = compute_pixel_to_sky_jacobians(wcs, x, y)[0] assert_allclose(np.abs(np.diag(jac)), WCS_CDELT_ARCSEC, rtol=1e-6) def test_sky_to_pixel_scales_unbiased_at_crval(self): wcs = _make_quadratic_sip_wcs() skycoord = SkyCoord(150.0 * u.deg, 0.0 * u.deg) _, major, minor, _ = sky_to_pixel_svd_scales(wcs, skycoord) assert_allclose([major, minor], 1.0 / WCS_CDELT_ARCSEC, rtol=1e-6) @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') class TestGWCSBoundingBox: """ Tests that the finite differences ignore a gwcs bounding box. The half-pixel offsets used to build the Jacobian at a source in the last pixel of the array fall outside the bounding box. gwcs returns NaN there when the box is honored, which would make the Jacobian of every edge source NaN. """ @pytest.fixture def bounded_gwcs(self): shape = (50, 60) gwcs = make_gwcs(shape) gwcs.bounding_box = ((-0.5, shape[1] - 0.5), (-0.5, shape[0] - 0.5)) return gwcs def test_pixel_to_sky_jacobians_finite_at_edge(self, bounded_gwcs): x = np.array([59.4, 0.0, 30.0]) y = np.array([10.0, 49.4, -0.4]) jacs = compute_pixel_to_sky_jacobians(bounded_gwcs, x, y) assert np.all(np.isfinite(jacs)) def test_edge_jacobian_matches_interior(self, bounded_gwcs): # The gwcs has no distortion, so the Jacobian is the same # everywhere jacs = compute_pixel_to_sky_jacobians(bounded_gwcs, np.array([59.4, 30.0]), np.array([10.0, 25.0])) assert_allclose(jacs[0], jacs[1], rtol=1e-6) def test_sky_to_pixel_scales_finite_at_edge(self, bounded_gwcs): skycoord = bounded_gwcs.pixel_to_world(59.4, 10.0) _, major, minor, _ = sky_to_pixel_svd_scales(bounded_gwcs, skycoord) assert np.isfinite(major) assert np.isfinite(minor) def test_wrapped_gwcs_finite_at_edge(self, bounded_gwcs): """ Test that a high-level wrapper around a gwcs object does not break the finite-difference Jacobian evaluation at the edge of the bounding box. """ wrapped = HighLevelWCSWrapper(bounded_gwcs) jacs = compute_pixel_to_sky_jacobians(wrapped, 59.4, 10.0) expected = compute_pixel_to_sky_jacobians(bounded_gwcs, 59.4, 10.0) assert np.all(np.isfinite(jacs)) assert_allclose(jacs, expected, rtol=1e-12) skycoord = bounded_gwcs.pixel_to_world(59.4, 10.0) _, major, minor, _ = sky_to_pixel_svd_scales(wrapped, skycoord) assert np.isfinite(major) assert np.isfinite(minor) def _reference_jacobians(x, y, wcs): """ Forward Jacobians from great-circle separations and position angles of the half-pixel offset points, independent of the implementation under test. """ x = np.atleast_1d(x).astype(float) y = np.atleast_1d(y).astype(float) sky0 = wcs.pixel_to_world(x, y) arcsec_per_rad = 3600.0 * np.degrees(1) jac = np.empty((x.size, 2, 2)) for col, (dx, dy) in enumerate(((0.5, 0.0), (0.0, 0.5))): lo = wcs.pixel_to_world(x - dx, y - dy) hi = wcs.pixel_to_world(x + dx, y + dy) s_lo, p_lo = sky0.separation(lo).rad, sky0.position_angle(lo).rad s_hi, p_hi = sky0.separation(hi).rad, sky0.position_angle(hi).rad jac[:, 0, col] = (s_hi * np.sin(p_hi) - s_lo * np.sin(p_lo)) jac[:, 1, col] = (s_hi * np.cos(p_hi) - s_lo * np.cos(p_lo)) return jac * arcsec_per_rad class TestJacobianEvaluation: """ Tests for how the vectorized Jacobian evaluates the WCS. """ def test_flattens_inputs(self, sip_wcs): yy, xx = np.mgrid[2:5, 3:7].astype(float) jacs = compute_pixel_to_sky_jacobians(sip_wcs, xx, yy) expected = compute_pixel_to_sky_jacobians(sip_wcs, xx.ravel(), yy.ravel()) assert jacs.shape == (12, 2, 2) assert_allclose(jacs, expected, rtol=1e-12) def test_size_mismatch(self, sip_wcs): match = 'x and y must have the same size' with pytest.raises(ValueError, match=match): compute_pixel_to_sky_jacobians(sip_wcs, [1.0, 2.0], [1.0]) def test_single_low_level_wcs_call(self, sip_wcs): """ Test that the vectorized Jacobian evaluation calls the WCS only once for all positions, rather than once per position. """ # The Jacobian needs only world coordinate values, so the # high-level API (which builds SkyCoord objects) is not used. wcs = CountingWCS(sip_wcs) compute_pixel_to_sky_jacobians(wcs, np.array([5.0, 12.0]), np.array([7.0, 3.0])) assert wcs.n_pixel_to_world_values == 1 assert wcs.n_pixel_to_world == 0 assert wcs.n_world_to_pixel == 0 @pytest.mark.parametrize('func', [ pixel_to_sky_mean_scale, pixel_to_sky_svd_scales, lambda wcs, pixcoord: pixel_shape_to_sky_svd(wcs, pixcoord, 2.0, 1.0, 0.3), ], ids=['mean_scale', 'svd_scales', 'shape_svd']) def test_pixel_to_sky_helpers_single_call(self, sip_wcs, func): pixcoord = (5.0, 12.0) expected = func(sip_wcs, pixcoord) wcs = CountingWCS(sip_wcs) result = func(wcs, pixcoord) assert wcs.n_pixel_to_world_values == 1 assert wcs.n_pixel_to_world == 0 assert wcs.n_world_to_pixel == 0 if not isinstance(result, tuple): result = (result,) expected = (expected,) for value, expected_value in zip(result, expected, strict=True): if isinstance(value, Angle): assert_allclose(value.deg, expected_value.deg, atol=1e-10) else: assert_allclose(value, expected_value, rtol=1e-12) def test_world_axis_units(self, sip_wcs): """ Test that the vectorized Jacobian evaluation respects the ``world_axis_units`` of the WCS, rather than assuming degrees. """ # The low-level API returns values in the WCS world axis units, # which must be converted rather than assumed to be degrees. class RadianWCS(CountingWCS): world_axis_units = ('rad', 'rad') def pixel_to_world_values(self, *args, **kwargs): lon, lat = super().pixel_to_world_values(*args, **kwargs) return np.deg2rad(lon), np.deg2rad(lat) x = np.array([5.0, 12.0]) y = np.array([7.0, 3.0]) expected = compute_pixel_to_sky_jacobians(sip_wcs, x, y) jacs = compute_pixel_to_sky_jacobians(RadianWCS(sip_wcs), x, y) assert_allclose(jacs, expected, rtol=1e-12) @pytest.mark.parametrize('wcs_name', ['simple_wcs', 'rotated_wcs', 'nonsquare_wcs', 'flipped_wcs', 'swapped_wcs', 'sip_wcs']) def test_agrees_with_separation_position_angle(self, wcs_name, request): wcs = request.getfixturevalue(wcs_name) x = np.array([3.0, 9.5, 16.2]) y = np.array([4.0, 9.5, 2.7]) jacs = compute_pixel_to_sky_jacobians(wcs, x, y) expected = _reference_jacobians(x, y, wcs) assert_allclose(jacs, expected, rtol=1e-7, atol=1e-7) def test_world_axis_order(self, simple_wcs, swapped_wcs): """ Test that a WCS with the latitude axis first gives the same Jacobians as the equivalent WCS with the longitude axis first. """ x = np.array([3.0, 9.5, 16.2]) y = np.array([4.0, 9.5, 2.7]) expected = compute_pixel_to_sky_jacobians(simple_wcs, x, y) jacs = compute_pixel_to_sky_jacobians(swapped_wcs, x, y) assert_allclose(jacs, expected, rtol=1e-9) skycoord = simple_wcs.pixel_to_world(x[0], y[0]) expected = sky_to_pixel_svd_scales(simple_wcs, skycoord) result = sky_to_pixel_svd_scales(swapped_wcs, skycoord) assert_allclose(result[0], expected[0], atol=1e-9) assert_allclose(result[1:3], expected[1:3], rtol=1e-9) assert_allclose(result[3].deg, expected[3].deg, atol=1e-9) def test_non_celestial_wcs(self): """ Test that a WCS without a celestial longitude and latitude pair is rejected with a clear message. """ wcs = APWCS(naxis=2) wcs.wcs.ctype = ['LINEAR', 'LINEAR'] wcs.wcs.cdelt = [0.1, 0.1] wcs = CountingWCS(wcs) match = 'exactly one celestial longitude axis' with pytest.raises(ValueError, match=match): compute_pixel_to_sky_jacobians(wcs, 5.0, 5.0) # The axes are checked before any transform is evaluated assert wcs.n_pixel_to_world_values == 0 @pytest.mark.parametrize(('center_ra', 'center_dec'), TROUBLESOME_CENTERS) def test_agrees_near_pole_and_wrap(self, center_ra, center_dec): wcs = _make_sip_wcs(center_ra, center_dec) x = np.array([9.5, 3.0]) y = np.array([9.5, 15.0]) jacs = compute_pixel_to_sky_jacobians(wcs, x, y) expected = _reference_jacobians(x, y, wcs) assert_allclose(jacs, expected, rtol=1e-7, atol=1e-7) @pytest.mark.parametrize('dec', [90.0, -90.0]) def test_exact_pole(self, dec): # Longitude is degenerate at the pole, but the pixel area and # the Jacobian must stay finite and correct. wcs = _make_sip_wcs(0.0, dec) jac = compute_pixel_to_sky_jacobians(wcs, 9.5, 9.5)[0] assert np.all(np.isfinite(jac)) assert_allclose(np.abs(np.linalg.det(jac)), WCS_CDELT_ARCSEC**2, rtol=1e-6) class TestCovarianceTransport: """ Monte Carlo tests of the pixel-to-sky error covariance transport. A pixel error covariance is mapped to the local tangent plane as ``F @ cov @ F.T`` with the forward Jacobian ``F``. Pixel positions drawn from that covariance and converted with the high-level WCS interface must scatter on the sky by the transported amount, along East and North and in their correlation. """ PIX_COV = np.array([[0.04, 0.01], [0.01, 0.09]]) N_DRAW = 50_000 @pytest.mark.parametrize('wcs_name', ['sheared', 'swapped_wcs', 'sip_wcs']) def test_scatter_matches_transport(self, wcs_name, request): if wcs_name == 'sheared': wcs = _make_sheared_wcs() xy0 = np.array([50.0, 50.0]) else: wcs = request.getfixturevalue(wcs_name) xy0 = np.array([12.3, 7.6]) jac = compute_pixel_to_sky_jacobians(wcs, *xy0)[0] sky_cov = jac @ self.PIX_COV @ jac.T rng = np.random.default_rng(0) draws = rng.multivariate_normal(xy0, self.PIX_COV, size=self.N_DRAW) center = wcs.pixel_to_world(*xy0) coords = wcs.pixel_to_world(draws[:, 0], draws[:, 1]) sep = center.separation(coords).rad pa = center.position_angle(coords).rad arcsec_per_rad = 3600.0 * np.degrees(1) east = sep * np.sin(pa) * arcsec_per_rad north = sep * np.cos(pa) * arcsec_per_rad mc_cov = np.cov(east, north) # The Monte Carlo precision of a standard deviation is 1 / # sqrt(2 N) = 0.3%, so the tolerances are several sigma. assert_allclose(np.sqrt(np.diag(mc_cov)), np.sqrt(np.diag(sky_cov)), rtol=0.02) mc_corr = mc_cov[0, 1] / np.sqrt(mc_cov[0, 0] * mc_cov[1, 1]) corr = sky_cov[0, 1] / np.sqrt(sky_cov[0, 0] * sky_cov[1, 1]) assert_allclose(mc_corr, corr, atol=0.02) class TestMeanScaleClosedForm: """ Tests that the mean pixel scale equals the geometric mean of the singular values of the Jacobian. """ @pytest.mark.parametrize('wcs_name', ['simple_wcs', 'rotated_wcs', 'nonsquare_wcs', 'flipped_wcs', 'swapped_wcs', 'sip_wcs']) def test_pixel_to_sky_matches_svd(self, wcs_name, request): wcs = request.getfixturevalue(wcs_name) x = np.array([3.0, 9.5, 16.2]) y = np.array([4.0, 9.5, 2.7]) jacs = compute_pixel_to_sky_jacobians(wcs, x, y) expected = np.sqrt(np.prod(np.linalg.svd(jacs, compute_uv=False), axis=1)) for i in range(x.size): scale = pixel_to_sky_mean_scale(wcs, (x[i], y[i])) assert_allclose(scale, expected[i], rtol=1e-12) def test_sky_to_pixel_matches_svd(self, nonsquare_wcs): _, scale = sky_to_pixel_mean_scale(nonsquare_wcs, WCS_CENTER) _, major, minor, _ = sky_to_pixel_svd_scales(nonsquare_wcs, WCS_CENTER) assert_allclose(scale, np.sqrt(major * minor), rtol=1e-12) class TestKnownPixelPosition: """ Tests for the ``pixcoord`` keyword of the sky-input helpers. When the pixel position of the sky coordinate is already known, the helpers must use it instead of inverting the WCS, and must return the same results as when they invert it themselves. The known position is taken from the WCS inverse so that both paths evaluate the Jacobian at the same place. """ @staticmethod def _known(wcs, x, y): skycoord = wcs.pixel_to_world(x, y) pixcoord = tuple(float(v) for v in wcs.world_to_pixel(skycoord)) return skycoord, pixcoord @pytest.fixture def known(self, sip_wcs): return self._known(sip_wcs, 12.0, 7.0) @pytest.mark.parametrize('wcs_name', ['simple_wcs', 'sip_wcs']) def test_mean_scale(self, wcs_name, request): real_wcs = request.getfixturevalue(wcs_name) skycoord, pixcoord = self._known(real_wcs, 12.0, 7.0) center, scale = sky_to_pixel_mean_scale(real_wcs, skycoord) wcs = CountingWCS(real_wcs) center2, scale2 = sky_to_pixel_mean_scale(wcs, skycoord, pixcoord=pixcoord) assert wcs.n_world_to_pixel == 0 assert_allclose(center2, center, atol=1e-10) assert_allclose(scale2, scale, rtol=1e-12) def test_svd_scales(self, sip_wcs, known): skycoord, pixcoord = known expected = sky_to_pixel_svd_scales(sip_wcs, skycoord) wcs = CountingWCS(sip_wcs) result = sky_to_pixel_svd_scales(wcs, skycoord, pixcoord=pixcoord) assert wcs.n_world_to_pixel == 0 assert_allclose(result[0], expected[0], atol=1e-10) assert_allclose(result[1:3], expected[1:3], rtol=1e-12) assert_allclose(result[3].deg, expected[3].deg, atol=1e-10) def test_shape_svd(self, sip_wcs, known): skycoord, pixcoord = known args = (2.0, 1.0, 0.3) expected = sky_shape_to_pixel_svd(sip_wcs, skycoord, *args) wcs = CountingWCS(sip_wcs) result = sky_shape_to_pixel_svd(wcs, skycoord, *args, pixcoord=pixcoord) assert wcs.n_world_to_pixel == 0 assert_allclose(result[0], expected[0], atol=1e-10) assert_allclose(result[1:3], expected[1:3], rtol=1e-12) assert_allclose(result[3].deg, expected[3].deg, atol=1e-10) def test_shape_svd_without_skycoord(self, sip_wcs, known): """ Test that the angle is taken to be in the WCS frame when the sky coordinate is None. """ skycoord, pixcoord = known args = (2.0, 1.0, 0.3) expected = sky_shape_to_pixel_svd(sip_wcs, skycoord, *args, pixcoord=pixcoord) result = sky_shape_to_pixel_svd(sip_wcs, None, *args, pixcoord=pixcoord) assert result[:3] == expected[:3] assert result[3] == expected[3] def _make_allsky_ait_wcs(): """ Build an all-sky Aitoff WCS with 1 degree pixels whose corner pixels lie outside the valid region of the projection. """ wcs = APWCS(naxis=2) wcs.wcs.crpix = [180.5, 90.5] wcs.wcs.crval = [0.0, 0.0] wcs.wcs.cdelt = [-1.0, 1.0] wcs.wcs.ctype = ['RA---AIT', 'DEC--AIT'] return wcs @pytest.fixture def ait_wcs(): return _make_allsky_ait_wcs() class _ConstantWCS(CountingWCS): """ Wrapper whose forward transform maps every pixel to the sky position of the wrapped WCS origin, so every Jacobian is zero. """ def pixel_to_world_values(self, x, y): """ Return the sky position of pixel (0, 0) for every input. Parameters ---------- x, y : `~numpy.ndarray` The pixel coordinates, used only for their shape. Returns ------- result : tuple of `~numpy.ndarray` The world coordinate values. """ return self._wcs.pixel_to_world_values(np.zeros_like(x), np.zeros_like(y)) class TestUndefinedWCS: """ Tests of the helpers at a pixel position where the WCS is undefined. The vectorized Jacobians propagate NaN silently, one element per position, while the per-region helpers raise a ValueError that names the position. """ OUTSIDE = (0.0, 0.0) def test_vectorized_helpers_return_nan(self, ait_wcs): x = np.array([self.OUTSIDE[0], 180.0]) y = np.array([self.OUTSIDE[1], 90.0]) with warnings.catch_warnings(): warnings.simplefilter('error') jacobians = compute_pixel_to_sky_jacobians(ait_wcs, x, y) assert np.all(np.isnan(jacobians[0])) assert np.all(np.isfinite(jacobians[1])) @pytest.mark.parametrize('func', [ pixel_to_sky_mean_scale, pixel_to_sky_svd_scales, lambda wcs, pixcoord: pixel_shape_to_sky_svd(wcs, pixcoord, 2.0, 1.0, 0.3), lambda wcs, pixcoord: sky_to_pixel_mean_scale(wcs, None, pixcoord=pixcoord), lambda wcs, pixcoord: sky_to_pixel_svd_scales(wcs, None, pixcoord=pixcoord), lambda wcs, pixcoord: sky_shape_to_pixel_svd(wcs, None, 2.0, 1.0, 0.3, pixcoord=pixcoord), ], ids=['pixel_to_sky_mean_scale', 'pixel_to_sky_svd_scales', 'pixel_shape_to_sky_svd', 'sky_to_pixel_mean_scale', 'sky_to_pixel_svd_scales', 'sky_shape_to_pixel_svd']) def test_per_region_helpers_raise(self, ait_wcs, func): match = r'The WCS is undefined at pixel position \(0\.0, 0\.0\)' with pytest.raises(ValueError, match=match): func(ait_wcs, self.OUTSIDE) @pytest.mark.parametrize('func', [ sky_to_pixel_mean_scale, sky_to_pixel_svd_scales, lambda wcs, skycoord, **kwargs: sky_shape_to_pixel_svd( wcs, skycoord, 2.0, 1.0, 0.3, **kwargs), ], ids=['sky_to_pixel_mean_scale', 'sky_to_pixel_svd_scales', 'sky_shape_to_pixel_svd']) @pytest.mark.parametrize('pixcoord', [None, (np.nan, np.nan)], ids=['inverted', 'given']) def test_sky_coordinate_without_pixel_position(self, simple_wcs, func, pixcoord): """ Test a sky coordinate that the projection cannot reach, so its pixel position is NaN whether the helper inverts the WCS or the caller passes the inverted position, as the apertures do. """ antipode = SkyCoord(WCS_CENTER.ra + 180 * u.deg, -WCS_CENTER.dec) match = (r'The sky coordinate \(280 -30 icrs\) has no pixel ' 'position under the WCS') with pytest.raises(ValueError, match=match): func(simple_wcs, antipode, pixcoord=pixcoord) def test_length_one_array_skycoord(self, simple_wcs): antipode = SkyCoord([WCS_CENTER.ra + 180 * u.deg], [-WCS_CENTER.dec]) match = r'The sky coordinate \(280 -30 icrs\) has no pixel position' with pytest.raises(ValueError, match=match): sky_to_pixel_mean_scale(simple_wcs, antipode) def test_nonfinite_pixcoord_without_skycoord(self, simple_wcs): match = r'The pixel position \(nan, 10\.0\) is not finite' with pytest.raises(ValueError, match=match): sky_to_pixel_mean_scale(simple_wcs, None, pixcoord=(np.nan, 10.0)) def test_singular_jacobian_raises(self, simple_wcs): """ Test a forward transform that maps every pixel to one sky position, so the Jacobian is finite but singular. """ wcs = _ConstantWCS(simple_wcs) match = (r'The WCS Jacobian is singular at pixel position ' r'\(10\.0, 10\.0\)') with pytest.raises(ValueError, match=match): sky_to_pixel_mean_scale(wcs, None, pixcoord=(10.0, 10.0)) def test_inverted_sky_position_raises(self, ait_wcs): """ Test the path where the pixel position is found by inverting the WCS rather than given. """ skycoord = SkyCoord(0.0 * u.deg, 0.0 * u.deg) # Every sky coordinate inverts to a valid pixel of an all-sky # map, so force the inversion to land outside the projection. wcs = CountingWCS(ait_wcs) wcs.world_to_pixel = lambda _skycoord: self.OUTSIDE with pytest.raises(ValueError, match='WCS is undefined'): sky_to_pixel_mean_scale(wcs, skycoord) class _FrameSpecWCS(CountingWCS): """ Wrapper whose celestial frame is given by ``frame``, which may be a frame name or class as in some gwcs objects. """ def __init__(self, real_wcs, frame): super().__init__(real_wcs) self._frame = frame @property def world_axis_object_classes(self): """ The world axis object classes of the wrapped WCS with the frame replaced. """ classes = self._wcs.world_axis_object_classes return {key: (cls, args, {**kwargs, 'frame': self._frame}) for key, (cls, args, kwargs) in classes.items()} class TestFrameRotation: """ Tests for the rotation between the frame of a sky coordinate and the celestial frame of a WCS. """ @staticmethod def _make_galactic_wcs(skycoord): wcs = APWCS(naxis=2) center = skycoord.galactic wcs.wcs.crval = [center.l.deg, center.b.deg] wcs.wcs.ctype = ['GLON-TAN', 'GLAT-TAN'] wcs.wcs.cdelt = [-0.1, 0.1] wcs.wcs.crpix = [25.5, 25.5] return wcs def test_same_frame(self, simple_wcs): assert _frame_rotation(simple_wcs, WCS_CENTER) == 0.0 def test_icrs_on_galactic_wcs(self): """ Test the position angle of ICRS North in Galactic coordinates at ICRS (0, 0) (regions issue #712). """ skycoord = SkyCoord(0 * u.deg, 0 * u.deg) wcs = self._make_galactic_wcs(skycoord) rotation = _frame_rotation(wcs, skycoord) assert_allclose(np.rad2deg(rotation), 23.47980599, atol=1e-6) def test_inverse_rotation(self, simple_wcs): """ Test that swapping the two frames negates the rotation. """ wcs = self._make_galactic_wcs(WCS_CENTER) forward = _frame_rotation(wcs, WCS_CENTER) inverse = _frame_rotation(simple_wcs, WCS_CENTER.galactic) assert abs(forward) > 0.1 assert_allclose(Angle(forward + inverse, u.rad).wrap_at(180 * u.deg), 0.0, atol=1e-10) @pytest.mark.parametrize(('frame', 'frame_instance'), [('icrs', ICRS()), (ICRS, ICRS()), ('galactic', Galactic()), (Galactic, Galactic())]) def test_frame_name_or_class(self, simple_wcs, frame, frame_instance): """ Test that a WCS frame given by name or class gives the same rotation as a frame instance. """ expected = _frame_rotation(_FrameSpecWCS(simple_wcs, frame_instance), WCS_CENTER) rotation = _frame_rotation(_FrameSpecWCS(simple_wcs, frame), WCS_CENTER) assert rotation == expected assert (rotation == 0.0) == isinstance(frame_instance, ICRS) astropy-photutils-24be529/photutils/utils/tests/wcs_test_helpers.py000066400000000000000000000124621526452531300261010ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ WCS builders and wrappers shared by the utils and aperture tests. The pytest fixtures that use these live in the utils ``conftest.py``. The helpers are kept in a plain module so that test files can import them without importing a ``conftest.py``, which pytest also loads as a plugin. """ import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from astropy.io.fits import Header 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_value(u.deg) 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(shape=(20, 20), *, center=WCS_CENTER, coeffs=None): """ Build a TAN-SIP WCS with the standard test pixel scale. Parameters ---------- shape : tuple of int, optional The ``(ny, nx)`` image shape. CRPIX is at the image center. center : `~astropy.coordinates.SkyCoord`, optional The sky position of CRPIX. coeffs : dict, optional The SIP coefficients, e.g., ``{'A_2_0': 1e-6, 'B_0_2': 1e-6}``. The polynomial orders are set from the highest given indices. Returns ------- wcs : `~astropy.wcs.WCS` The WCS object with SIP distortion. """ header = Header() header['NAXIS'] = 2 header['NAXIS1'] = shape[1] header['NAXIS2'] = shape[0] header['CRPIX1'] = shape[1] / 2 + 0.5 header['CRPIX2'] = shape[0] / 2 + 0.5 header['CRVAL1'] = center.ra.deg header['CRVAL2'] = center.dec.deg header['CTYPE1'] = 'RA---TAN-SIP' header['CTYPE2'] = 'DEC--TAN-SIP' cdelt = WCS_CDELT_ARCSEC / 3600.0 header['CD1_1'] = -cdelt header['CD1_2'] = 0.0 header['CD2_1'] = 0.0 header['CD2_2'] = cdelt coeffs = coeffs or {} for prefix in ('A', 'B'): orders = [int(key[2]) + int(key[4]) for key in coeffs if key.startswith(prefix)] header[f'{prefix}_ORDER'] = max(orders, default=2) for key, value in coeffs.items(): header[key] = value return WCS(header) class CountingWCS: """ Wrapper that counts the calls to the WCS transform methods. Parameters ---------- real_wcs : WCS object The WCS that performs the transforms. """ def __init__(self, real_wcs): self._wcs = real_wcs self.n_pixel_to_world = 0 self.n_pixel_to_world_values = 0 self.n_world_to_pixel = 0 @property def world_axis_units(self): """ The world axis units of the wrapped WCS. """ return self._wcs.world_axis_units @property def world_axis_object_components(self): """ The world axis object components of the wrapped WCS. """ return self._wcs.world_axis_object_components @property def world_axis_object_classes(self): """ The world axis object classes of the wrapped WCS. """ return self._wcs.world_axis_object_classes def pixel_to_world(self, *args, **kwargs): """ Count and forward a high-level pixel-to-world call. Parameters ---------- *args, **kwargs Passed to the wrapped WCS. Returns ------- result : object The result of the wrapped WCS call. """ self.n_pixel_to_world += 1 return self._wcs.pixel_to_world(*args, **kwargs) def pixel_to_world_values(self, *args, **kwargs): """ Count and forward a low-level pixel-to-world call. Parameters ---------- *args, **kwargs Passed to the wrapped WCS. Returns ------- result : object The result of the wrapped WCS call. """ self.n_pixel_to_world_values += 1 return self._wcs.pixel_to_world_values(*args, **kwargs) def world_to_pixel(self, *args, **kwargs): """ Count and forward a high-level world-to-pixel call. Parameters ---------- *args, **kwargs Passed to the wrapped WCS. Returns ------- result : object The result of the wrapped WCS call. """ self.n_world_to_pixel += 1 return self._wcs.world_to_pixel(*args, **kwargs) astropy-photutils-24be529/photutils/utils/wcs.py000066400000000000000000000254351526452531300221620ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tools for computing the on-sky area of image pixels from a WCS. """ import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._wcs_helpers import compute_pixel_to_sky_jacobians __all__ = ['compute_pixel_area_map', 'compute_pixel_areas'] def compute_pixel_areas(wcs, x, y): """ Compute the on-sky area of the pixels at the given positions. The area of a pixel is the area of the parallelogram spanned by its two edge vectors on the sky, which is the absolute determinant of the local forward WCS Jacobian ``d(sky_arcsec)/d(pixel)``. The Jacobian is evaluated by central finite differences half a pixel either side of each position, so the area is exact for a locally quadratic distortion in the small-angle limit. The finite pixel size adds a relative error of order ``h**2`` for a pixel that subtends an angle ``h`` in radians. The size of the error depends on the projection. It is below 1e-6 at 0.1 deg per pixel and negligible for imaging data. The area is well defined at the celestial poles and across the longitude wraparound. 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`). x, y : float or array_like The pixel coordinates. Both must have the same shape. Returns ------- areas : float or `~numpy.ndarray` The pixel areas in arcsec\\ :sup:`2`, with the same shape as ``x`` and ``y``. A float is returned for scalar input. The area is NaN for a nonfinite position or a position where the WCS is undefined (e.g., outside the valid region of the projection). See Also -------- compute_pixel_area_map Notes ----- The pixel area of a distorted image typically varies by a few percent across the field. Multiplying a surface brightness image (e.g., in MJy/sr) by a single nominal pixel area therefore leaves a position-dependent error in the resulting fluxes. This function gives the area of each pixel from the WCS instead. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_wcs >>> from photutils.utils import compute_pixel_areas >>> wcs = make_wcs((100, 100)) >>> areas = compute_pixel_areas(wcs, [10.0, 50.0], [20.0, 50.0]) >>> print(np.round(areas, 6)) # arcsec**2 [0.01 0.01] """ x = np.asarray(x, dtype=float) y = np.asarray(y, dtype=float) if x.shape != y.shape: msg = 'x and y must have the same shape' raise ValueError(msg) jacobians = compute_pixel_to_sky_jacobians(wcs, x, y) # The explicit 2x2 determinant is much faster than np.linalg.det on # a stack of small matrices and propagates NaN without raising a # warning. areas = np.abs(jacobians[:, 0, 0] * jacobians[:, 1, 1] - jacobians[:, 0, 1] * jacobians[:, 1, 0]) # An infinite position gives coincident offset points and thus a # zero area. Report NaN for any nonfinite position instead. finite = (np.isfinite(x) & np.isfinite(y)).ravel() areas = np.where(finite, areas, np.nan).reshape(x.shape) if areas.ndim == 0: return float(areas) return areas def _is_positive_int(value): """ Return whether ``value`` is a positive integer (and not a bool). """ return (isinstance(value, (int, np.integer)) and not isinstance(value, (bool, np.bool_)) and value > 0) def _compute_areas_at_every_pixel(wcs, ny, nx): """ Evaluate `compute_pixel_areas` directly at every pixel of an image with the given ``(ny, nx)`` shape. The image is evaluated in blocks of rows so that the temporary arrays of the Jacobian computation, which take about 500 bytes per pixel at their peak, stay bounded for large images. The result is identical to a single evaluation of the whole image. """ max_block_pixels = 2**18 n_rows = max(1, max_block_pixels // nx) areas = np.empty((ny, nx)) x = np.arange(nx, dtype=float) for y0 in range(0, ny, n_rows): y1 = min(y0 + n_rows, ny) xx, yy = np.meshgrid(x, np.arange(y0, y1, dtype=float)) areas[y0:y1] = compute_pixel_areas(wcs, xx, yy) return areas def compute_pixel_area_map(wcs, shape, *, interpolate=True, step=None): """ Compute the on-sky area of every pixel in an image. By default, the areas are computed with `compute_pixel_areas` on a coarse grid of positions and then interpolated onto the full image grid with a bicubic spline. Distortions vary smoothly on scales far larger than the grid spacing, so the interpolation error is negligible compared with the variation in area that the map captures. Set ``interpolate=False`` to instead evaluate the area directly at every pixel. 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`). shape : 2-tuple of int The ``(ny, nx)`` shape of the image. interpolate : bool, optional Whether to interpolate the areas from a coarse grid. If `False`, the area is evaluated directly at every pixel with `compute_pixel_areas`. That is exact but slower (a few seconds for a 4096 x 4096 image). For any image more than a few dozen pixels on a side, interpolation is faster than direct evaluation at every step of 2 or more (about 3 times at a step of 2 and more than 10 times at a step of 4), so a small step is not a reason to disable it. Set ``interpolate=False`` only when the exact per-pixel areas are wanted. step : int or None, optional The spacing in pixels of the coarse grid on which the areas are evaluated. The grid extends two steps before the first pixel and at least one step beyond the last pixel, so the interpolation never extrapolates and the spline is well defined for any image shape. Steps from about 32 to 128 give equivalent results. Smaller steps only add runtime and larger steps lose accuracy on strongly distorted or very wide fields. The largest allowed step is ``min(shape) // 8`` (and at least 1), so that the coarse grid always has at least eight intervals across the image. If `None`, the step is 64 or the largest allowed step, whichever is smaller. A larger step than allowed is reduced to the largest allowed step with a warning. A step of 1 samples every pixel, so the area is then evaluated directly at every pixel without interpolation, as if ``interpolate`` were `False`. ``step`` must be `None` if ``interpolate`` is `False`. Returns ------- areas : `~numpy.ndarray` The 2D array of pixel areas in arcsec\\ :sup:`2`, with the same shape as the image. The area is NaN for pixels where the WCS is undefined (e.g., outside the valid region of the projection). See Also -------- compute_pixel_areas Notes ----- If any node of the padded coarse grid lies outside the valid region of the projection, which can happen for an all-sky map even when every image pixel is valid, the areas are instead evaluated directly at every pixel with `compute_pixel_areas`, as if ``interpolate`` were `False`. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_wcs >>> from photutils.utils import compute_pixel_area_map >>> shape = (100, 100) >>> wcs = make_wcs(shape) >>> areas = compute_pixel_area_map(wcs, shape) >>> areas.shape (100, 100) >>> print(np.round(areas[50, 50], 6)) # arcsec**2 0.01 """ msg = 'shape must be two positive integers' try: ny, nx = shape except (TypeError, ValueError): raise ValueError(msg) from None if not (_is_positive_int(ny) and _is_positive_int(nx)): raise ValueError(msg) if not isinstance(interpolate, (bool, np.bool_)): msg = f'interpolate must be a boolean, got {interpolate!r}' raise TypeError(msg) if not interpolate: if step is not None: msg = 'step must be None when interpolate=False' raise ValueError(msg) return _compute_areas_at_every_pixel(wcs, ny, nx) # Ensure the image spans at least eight grid intervals so that large # steps on smaller images do not leave the spline with too few # knots. max_step = max(1, min(ny, nx) // 8) if step is None: step = min(64, max_step) elif not _is_positive_int(step): msg = 'step must be a positive integer or None' raise ValueError(msg) elif step > max_step: if max_step == 1: msg = (f'step={step} was reduced to 1, so the area is ' 'evaluated directly at every pixel') else: msg = (f'step={step} was reduced to {max_step} so that the ' 'coarse grid has at least eight intervals across the ' 'image') warnings.warn(msg, AstropyUserWarning) step = max_step # A step of 1 samples every pixel, so evaluate the areas directly # instead of interpolating them from themselves. This check comes # after the step is validated and capped so that a capped step still # warns before taking the direct path. if step == 1: return _compute_areas_at_every_pixel(wcs, ny, nx) # Coarse grid padded by two steps beyond each edge. This gives the # spline at least five knots per axis and keeps the interpolation # inside the sampled region for every pixel. grid_y = np.arange(-2 * step, ny + 2 * step, step, dtype=float) grid_x = np.arange(-2 * step, nx + 2 * step, step, dtype=float) xx, yy = np.meshgrid(grid_x, grid_y) coarse = compute_pixel_areas(wcs, xx, yy) # A NaN anywhere on the coarse grid would make the spline NaN at # every pixel. That happens when a node lies outside the valid # region of the projection, which the padding can do even when every # image pixel is valid (e.g., an all-sky map). Evaluate the areas # directly at every pixel in that case. if not np.all(np.isfinite(coarse)): return _compute_areas_at_every_pixel(wcs, ny, nx) # scipy.interpolate is slow to import, so it is imported only when # needed. from scipy.interpolate import RectBivariateSpline spline = RectBivariateSpline(grid_y, grid_x, coarse) return spline(np.arange(ny, dtype=float), np.arange(nx, dtype=float)) astropy-photutils-24be529/pyproject.toml000066400000000000000000000212201526452531300205210ustar00rootroot00000000000000[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.12' dependencies = [ 'astropy >= 7.0', 'numpy >= 2.0', 'scipy >= 1.15.0', ] [project.urls] Homepage = 'https://github.com/astropy/photutils' Documentation = 'https://photutils.readthedocs.io/en/stable/' [project.entry-points] 'asdf.extensions' = {photutils = 'photutils.extension:get_extensions'} 'asdf.resource_mappings' = {photutils = 'photutils.extension:get_resource_mappings'} [project.optional-dependencies] all = [ 'asdf >= 3.3', 'asdf-astropy >= 0.10', 'bottleneck >= 1.4', 'gwcs >= 0.22', 'matplotlib >= 3.10', 'rasterio >= 1.4', 'regions >= 0.10', 'shapely >= 2.1', 'tqdm >= 4.67', ] test = [ 'asdf >= 3.3', 'asdf-astropy >= 0.10', 'pytest-asdf-plugin', 'pytest >= 8.0', 'pytest-astropy-header >= 0.2.2', 'pytest-doctestplus >= 1.4', 'pytest-filter-subpackage >= 0.2', 'pytest-remotedata >= 0.4.1', 'pytest-xdist >= 3.7', 'tox >= 4.18', ] docs = [ 'photutils[all]', 'sphinx >= 8.2', # keep in sync with docs/conf.py 'sphinx-astropy[confv3] >= 1.11', 'sphinx_design >= 0.7', 'sphinx-reredirects >= 1.1', ] dev = [ 'photutils[docs,test]', 'pre-commit >= 4.0', ] [build-system] requires = [ 'cython >= 3.1.2, < 4', 'extension-helpers >= 1.4, < 2', 'numpy >= 2.0', 'scipy >= 1.15.0', 'setuptools >= 77.0.1', 'setuptools_scm >= 8.2', ] 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.resources' = [ '**/*.yaml' ] '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' doctest_optionflags = [ 'ELLIPSIS', # default 'FLOAT_CMP', 'NORMALIZE_WHITESPACE', # default ] 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 ] asdf_schema_root = 'photutils/resources/schemas' asdf_schema_tests_enabled = true [tool.coverage.run] omit = [ 'photutils/__init__*', 'photutils/**/conftest.py', 'photutils/**/setup*', 'photutils/**/tests/*', 'photutils/**/*.pyx', 'photutils/extern/*', 'photutils/version*', '*/photutils/__init__*', '*/photutils/**/conftest.py', '*/photutils/**/setup*', '*/photutils/**/tests/*', '*/photutils/**/*.pyx', '*/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-*"] environment = { EXTENSION_HELPERS_PY_LIMITED_API = "cp312" } [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, reenable, """ [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 ] # Do not 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 'to_yaml_tree', # ASDF converter 'from_yaml_tree', # ASDF converter # PR01: private classes/functions 'SigmaClipSentinelDefault$', 'create_default_sigmaclip$', 'create_matching_kernel$', # PR02: subclasses without __init__ 'Background$', 'BackgroundRMS$', 'BackgroundBase$', 'BackgroundRMSBase$', '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 'PLR0917', # too-many-positional-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 ] 'scripts/*.py' = [ 'INP001', # not a package ] 'dev/*' = [ 'INP001', # not a package 'T201', # print ] 'benchmarks/*.py' = [ 'INP001', # not a package 'T201', # print ] '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.flake8-quotes] inline-quotes = 'single' [tool.ruff.lint.flake8-copyright] min-file-size = 1 # skip empty files (< 1 byte), e.g., __init__.py notice-rgx = 'Licensed under a 3-clause BSD style license - see LICENSE.rst' [tool.ruff.lint.pydocstyle] convention = 'numpy' [tool.cython-lint] max-line-length = 79 astropy-photutils-24be529/scripts/000077500000000000000000000000001526452531300172775ustar00rootroot00000000000000astropy-photutils-24be529/scripts/pin_minimum_requirements.py000066400000000000000000000060521526452531300250000ustar00rootroot00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Generate a pip requirements file pinning build and runtime dependencies to their minimum supported versions, as declared in pyproject.toml. By default, reads [build-system].requires and [project].dependencies and writes _oldest_deps.txt. With --all, also includes the "all" extra and writes _oldest_deps-all.txt. """ import argparse import sys import tomllib from pathlib import Path from packaging.requirements import Requirement from packaging.version import Version script_name = Path(__file__).name out_path = Path(__file__).parent pyproject = out_path.parent / 'pyproject.toml' data = tomllib.loads(pyproject.read_text()) def pin_minimum(req_str_sections): """ Return a list of pinned requirement strings with the minimum versions for each package. Parameters ---------- req_str_sections : list of list of str A list of sections, where each section is a list of requirement strings (e.g., from [build-system].requires or [project].dependencies). Returns ------- result : list of str A list of requirement strings with minimum versions pinned (e.g., 'package==1.2.3'). """ mins = {} for section in req_str_sections: for req_str in section: req = Requirement(req_str) name = req.name.lower() for spec in req.specifier: if spec.operator == '>=': ver = Version(spec.version) if name not in mins or ver > mins[name][1]: mins[name] = (req.name, ver) break return [f'{name}=={ver}' for name, ver in mins.values()] parser = argparse.ArgumentParser( description=('Generate oldest-dependency constraint files from ' 'pyproject.toml.'), epilog=f'Example: python {script_name} --all', ) parser.add_argument( '--all', action='store_true', dest='include_all', help='also include the "all" extras in install constraints', ) parser.add_argument( '--path', type=Path, default=out_path, metavar='DIR', help=('directory where the output file will be written ' '(default: %(default)s)'), ) args = parser.parse_args() # Include project name in basename basename = data['project']['name'].replace('-', '_') + '_oldest_deps' sections = [data['build-system']['requires'], data['project']['dependencies']] if args.include_all: sections.append(data['project']['optional-dependencies']['all']) filename = f'{basename}-all.txt' else: filename = f'{basename}.txt' pins = pin_minimum(sections) output = args.path / filename header = ('# This file was generated by pin_minimum_requirements.py.\n' '# It pins the minimum supported versions of dependencies as ' 'declared in\n# pyproject.toml, including "build-system" ' 'dependencies (e.g., Cython).\n') output.write_text(header + '\n'.join(pins) + '\n') sys.stdout.write(f'Wrote {output}:\n') for p in pins: sys.stdout.write(f' {p}\n') astropy-photutils-24be529/tox.ini000066400000000000000000000122101526452531300171170ustar00rootroot00000000000000[tox] envlist = py{312}-test-oldestdeps py{312,313,314,315}-test{,-alldeps,-devdeps,-predeps,-devinfra}{,-cov} oldest_cython build_docs linkcheck codestyle pep517 bandit asdf-schemas 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 predeps: with the latest pre-release versions of key dependencies devdeps: with the latest developer versions of key dependencies devinfra: like devdeps but also dev versions of infrastructure packages oldestdeps: with the oldest supported versions of key dependencies cov: and test coverage # The following provides some specific pinnings for key packages deps = cov: pytest-cov oldestdeps: packaging devdeps: numpy>=0.0.dev0 devdeps: scipy>=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-filter-subpackage.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 # oldestdeps includes oldest build dependencies (e.g., Cython). commands_pre = oldestdeps: python -c "import sys; assert (3,12) <= sys.version_info < (3,13), 'Requires Python 3.12'" oldestdeps-!alldeps: python {toxinidir}/scripts/pin_minimum_requirements.py --path {envdir} oldestdeps-!alldeps: python -m pip install -r {envdir}/photutils_oldest_deps.txt # Older pyparsing is needed to avoid pyparsing deprecation warnings # caused by matplotlib. oldestdeps-alldeps: python -m pip install "pyparsing==3.2.5" oldestdeps-alldeps: python {toxinidir}/scripts/pin_minimum_requirements.py --path {envdir} --all oldestdeps-alldeps: python -m pip install -r {envdir}/photutils_oldest_deps-all.txt commands = pip freeze --all pytest --pyargs photutils {toxinidir}/docs \ cov: --cov photutils --cov-config={toxinidir}/pyproject.toml --cov-report xml:{toxinidir}/coverage.xml --cov-report term-missing \ {posargs} pip_pre = predeps: true !predeps: false [testenv:build_docs] changedir = docs description = Generate the HTML documentation using sphinx-build extras = docs commands = pip freeze --all sphinx-build -W -b html . _build/html [testenv:linkcheck] changedir = docs description = Check the links in the HTML docs extras = docs commands = pip freeze --all 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 = Build source distribution and check with twine 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 [testenv:asdf-schemas] changedir = . description = Run ASDF schema validation deps = asdf-astropy extras = test commands = pip freeze --all pytest photutils/resources/schemas {posargs} [testenv:oldest_cython] skip_install = true changedir = . description = Check Cython compilation with the oldest supported Cython version deps = packaging commands_pre = python -c "import sys; assert (3,12) <= sys.version_info < (3,13), 'Requires Python 3.12'" python {toxinidir}/scripts/pin_minimum_requirements.py --path {envdir} python -m pip install -r {envdir}/photutils_oldest_deps.txt commands = pip freeze --all python -c "from Cython.Build import cythonize; cythonize('photutils/**/*.pyx', force=True, language_level='3')"